pythonmultithreadingtkintertello-drone

How Do I Create New Threads/Kill Alive Threads for a Button in Tkinter?


I'm currently working on a Tkinter GUI for my DJI Tello, and I'm trying to make it so that when I command the drone to takeoff/land, the streamed video on the GUI does not freeze. I'm not too familiar with multithreading, but I looked the issue up and it seems like I'm not the only one encountering this. So I used what I found regarding threading and starting threads, and ended up with this line (more or less):

forward_button = Button(root, text="Takeoff/Land", font=("Verdana", 18), bg="#95dff3", command=threading.Thread(target=lambda: takeoff_land(flydo)).start)

Now when I press the button, the drone takes off and the video no longer freezes. However, when I click it again, the code throws an error:

RuntimeError: threads can only be started once

But I want my button to be able to have the drone take off when it's landed, and then land when it's flying. Is there a way I can do that?

Here is what I have so far (in the takeoff_land() function, I set up some testing code in place of the actual commands. Basically, I want it to be able to start printing 0, 1, 2... afterwards even if it's already printing.) Most of it is just GUI stuff, but I didn't want to omit anything that would break the code.

import cv2
import threading
from djitellopy import tello
from tkinter import *
from PIL import Image, ImageTk

import time

def takeoff_land(flydo):
    '''Flydo takes off if not flying, lands if flying.'''
    global flying
    if flying:
        for i in range(10):
            print(i)
            time.sleep(1)
        # flydo.land()
        flying = False
    else:
        for i in range(10):
            print(i)
            time.sleep(1)
        # flydo.takeoff()
        flying = True
    

def run_app(HEIGHT=800, WIDTH=800):
    root = Tk()
    
    flydo = tello.Tello()
    flydo.connect()
    flydo.streamon()

    global flying
    flying = False # To toggle between takeoff and landing for button

    canvas = Canvas(root, height=HEIGHT, width=WIDTH)
    
    # For background image
    bg_dir = "C:\\Users\\charl\\Desktop\\flydo\\Tacit.jpg"
    img = Image.open(bg_dir).resize((WIDTH, HEIGHT))
    bg_label = Label(root)
    bg_label.img = ImageTk.PhotoImage(img)
    bg_label["image"] = bg_label.img
    bg_label.place(x=0, y=0, relwidth=1, relheight=1)

    # Display current battery
    battery = Label(text=f"Battery: {int(flydo.get_battery())}%", font=("Verdana", 18), bg="#95dff3")
    bat_width = 200
    bat_height = 50
    battery.config(width=bat_width, height=bat_height)
    battery.place(x=(WIDTH - bat_width - 0.1*HEIGHT + bat_height), rely=0.9, relwidth=bat_width/WIDTH, relheight=bat_height/HEIGHT)

    # Takeoff/Land button
    forward_button = Button(root, text="Takeoff/Land", font=("Verdana", 18), bg="#95dff3", command=threading.Thread(target=lambda: takeoff_land(flydo)).start)
    if threading.Thread(target=lambda: takeoff_land(flydo)).is_alive():
        threading.Thread(target=lambda: takeoff_land(flydo)).join() # This doesn't kill the thread the way I want it to...
    fb_width = 200
    fb_height = 100
    forward_button.config(width=fb_width, height=fb_height)
    forward_button.place(x=(WIDTH/2 - fb_width/2), rely=0.61, relwidth=fb_width/WIDTH, relheight=fb_height/HEIGHT)

    cap_label = Label(root)
    cap_label.pack()
    
    def video_stream():
        h = 480
        w = 720
        frame = flydo.get_frame_read().frame
        frame = cv2.resize(frame, (w, h))
        cv2image = cv2.cvtColor(frame, cv2.COLOR_BGR2RGBA)
        img = Image.fromarray(cv2image)
        imgtk = ImageTk.PhotoImage(image=img)
        cap_label.place(x=WIDTH/2 - w/2, y=0)
        cap_label.imgtk = imgtk
        cap_label.configure(image=imgtk)
        cap_label.after(5, video_stream)

    video_stream()
    canvas.pack()
    root.mainloop()


if __name__ == "__main__":
    HEIGHT = 800
    WIDTH = 800

    run_app(HEIGHT, WIDTH)

Solution

  • Ok so I actually managed to figure out the solution this morning based on this article:

    https://bhaveshsingh0124.medium.com/multi-threading-on-python-tkinter-button-f0d9f759ad3e

    Essentially, what I had to do was thread the Tello commands WITHIN the function that I'm calling with the button, rather than that function itself. Since the drone can only land OR take off, it can create a new thread each time one of those two commands is called. Here is the fixed code:

    import cv2
    import threading
    from djitellopy import tello
    from tkinter import *
    from PIL import Image, ImageTk # You have to import this last or else Image.open throws an error
    import time
    
    def dummy_tello_fn():
        for i in range(3):
            print(i)
            time.sleep(1)
    
    
    def takeoff_land(flydo):
        '''Flydo takes off if not flying, lands if flying.'''
        global flying
        if flying:
            # threading.Thread(target=lambda: dummy_tello_fn()).start()
            threading.Thread(target=lambda: flydo.land()).start()
            flying = False
        else:
            # threading.Thread(target=lambda: dummy_tello_fn()).start()
            threading.Thread(target=lambda: flydo.takeoff()).start()
            flying = True
        
    
    def run_app(HEIGHT=800, WIDTH=800):
        root = Tk()
        
        flydo = tello.Tello()
        flydo.connect()
        flydo.streamon()
    
        global flying
        flying = False # To toggle between takeoff and landing for button
    
        canvas = Canvas(root, height=HEIGHT, width=WIDTH)
        
        # For background image
        bg_dir = "C:\\Users\\charl\\Desktop\\flydo\\Tacit.jpg"
        img = Image.open(bg_dir).resize((WIDTH, HEIGHT))
        bg_label = Label(root)
        bg_label.img = ImageTk.PhotoImage(img)
        bg_label["image"] = bg_label.img
        bg_label.place(x=0, y=0, relwidth=1, relheight=1)
    
        # Display current battery
        battery = Label(text=f"Battery: {int(flydo.get_battery())}%", font=("Verdana", 18), bg="#95dff3")
        bat_width = 200
        bat_height = 50
        battery.config(width=bat_width, height=bat_height)
        battery.place(x=(WIDTH - bat_width - 0.1*HEIGHT + bat_height), rely=0.9, relwidth=bat_width/WIDTH, relheight=bat_height/HEIGHT)
    
        # Takeoff/Land button
        forward_button = Button(root, text="Takeoff/Land", font=("Verdana", 18), bg="#95dff3", command=lambda: takeoff_land(flydo))
        fb_width = 200
        fb_height = 100
        forward_button.config(width=fb_width, height=fb_height)
        forward_button.place(x=(WIDTH/2 - fb_width/2), rely=0.61, relwidth=fb_width/WIDTH, relheight=fb_height/HEIGHT)
    
        cap_label = Label(root)
        cap_label.pack()
        
        def video_stream():
            h = 480
            w = 720
            frame = flydo.get_frame_read().frame
            frame = cv2.resize(frame, (w, h))
            cv2image = cv2.cvtColor(frame, cv2.COLOR_BGR2RGBA)
            img = Image.fromarray(cv2image)
            imgtk = ImageTk.PhotoImage(image=img)
            cap_label.place(x=WIDTH/2 - w/2, y=0)
            cap_label.imgtk = imgtk
            cap_label.configure(image=imgtk)
            cap_label.after(5, video_stream)
    
        video_stream()
        canvas.pack()
        root.mainloop()
    
    
    if __name__ == "__main__":
        HEIGHT = 800
        WIDTH = 800
    
        run_app(HEIGHT, WIDTH)