Python Tkinter Images Tearing On Scroll

Question:

I’m working on a project to create a GUI application to display images as clickable buttons in Tkinter. I have the functionality completed where the images appear in a scroll-able grid on the GUI. However, when scrolling up or down there is terrible screen tearing with the images. Is there a way to work around this with Tkinter or is this due to python not being the fastest language and graphical operations being somewhat expensive? If I have to rewrite the program in C++ for example to be faster that is fine, but I would like to know if such a measure is necessary before taking that step. I am new to GUI applications so if im making some obvious mistake please let me know.

Thanks for your help ahead of time, code below:


import os
import json
import tkinter as tk
from tkinter import ttk
from math import *
from tkinter import *
from PIL import ImageTk, Image

directory = "IMG DIR HERE!" #<--CHANGE THIS FOR YOUR SYSTEM!
class Img: 
    def __init__(self,name=-2,img=-2):
        self.name = name
        self.img = img


def event(args):
    canvas.configure(scrollregion = canvas.bbox("all"))

def command(): 
   
    print("Hello")


def resize_img(raw_img):
    std_size = (300,300) 
    img = raw_img.resize(std_size)
    return img



def getimages(): 
   
    images = os.listdir(directory); 

    Imgs=[]
    exts = ["gif","jpg","png"]
    for name in images:
        ext = name.split(".")[1]
        print(ext)
        if ext not in exts: 
            print("False")
            continue        
        print("True")
        raw_img = Image.open(directory + "/"+name)
        img = resize_img(raw_img)
        img = ImageTk.PhotoImage(img) 
        img = Img(name,img)
        Imgs.append(img)
    
    return Imgs

root= tk.Tk()
root.geometry("1000x400")
root.title("Display Image App")


images = getimages()



#Create A Main Frame
main_frame = Frame(root)
main_frame.pack(fill=BOTH,expand=1)


#Create A Canvas
canvas = Canvas(main_frame)
canvas.pack(side=LEFT, fill=BOTH, expand=1) 

#Add A Scrollbar to the Canvas 
scrollbar = Scrollbar(main_frame, orient=VERTICAL, command=canvas.yview)
scrollbar.pack(side=RIGHT,fill=Y)

#Configure Canvas
canvas.configure(yscrollcommand=scrollbar.set)
canvas.bind('<Configure>',  event)

#Create another Frame inside the Canvas
imageframe = Frame(canvas)

#add that new frame to a window in the canvas 
canvas.create_window((0,0), window=imageframe, anchor="nw")



num_images = len(images)
num_images_per_col = 4
num_rows = ceil(num_images/num_images_per_col)


for i in range(num_images_per_col): 
    imageframe.columnconfigure(i, weight=1)


index=0
for i in range(num_rows): 
    for j in range(num_images_per_col): 
        if index > num_images-1: 
            break
        img = images[index].img
        button_img = Button(imageframe,image=img, command=command,borderwidth=2)
        button_img.grid(row=i,column=j, pady=5)
        index += 1



root.mainloop()
Asked By: Michael

||

Answers:

[not an answer that fixes your problem. sorry]

So, I took care of running your code, and made some changes so that is more friendly (mostly: moved the top-level code into a function – always use functions as they are far more tractable, despite there being little improvements in this code as it is).

As I wrote in the comments: this is not a problem innerent to Python high-level approach – as all image operations per-se (reading, re-scaling, displaying, screen scrolling) are offset by the respective libraries to native code.You’d certainly face the Very same issues with a C++ or Rust program that would interface with TCL/TL for creating the interface, and use this eager approach to pre-load and assemble all images in a grid.

It worked fine in a directory with ~20 images – but it will likely show up the problems you are describing in a directory with hundreds or thousands of images – because each image will use memory resources when loaded, and possibly tkinter is inneficient when assembling a component with hundreds of them using "grid" (having to loop upon each component when scrolling to do whatever internally, for example).

The way out would be to write the code so that it would load the PhotoImages lazily, as the scroll would take place, and dispose of older images that fall out of view.
Maybe keeping some 5-6 rows in advance, and dropping them in after they are 5-6 rows out of the screen. However the code for that would involve another order of complexity, compared to simply pre-load everything eagerly as your example does.

As mentioned in the comments, it is possible that switching for Qt or GTK would fix your problems there – or maybe even using Python to serve a web-page (in the local computer), using Flask, for example: the advantage of this approach is that all the UI part, including image handling is taken over by the browser, and the Python part has only to worry about image-paths, and eventual processing you may want to perform. Keep in mind that for tens of thousands of images, even switching the underlying graphic approach may still not give you a good performance – the programmatic approach of loading a subset of your images, limited to a couple hundred at a time, on the other hand, will work with any of those.

I am posting the script I run here – it won’t fix your problem, but I arranged it to be contained in a function and to make use of Python’s pathlib for path and file access in a way it is more confortable:

import os
import json
import tkinter as tk
from tkinter import ttk
from math import *
from tkinter import *
from PIL import ImageTk, Image
import sys
from pathlib import Path

directory = sys.argv[1]

class Img:
    def __init__(self,name=-2,img=-2):
        self.name = name
        self.img = img


def event(args):
    canvas.configure(scrollregion = canvas.bbox("all"))

def command():

    print("Hello")


def resize_img(raw_img):
    std_size = (300,300)
    img = raw_img.resize(std_size)
    return img



def getimages():
    exts = [".gif",".jpg",".png"]
    images = [path for path in Path(directory).iterdir() if path.suffix in exts]

    Imgs=[]
    for path in images:
        print(path.suffix)
        print("True")
        try:
            raw_img = Image.open(path)
        except Exception as err:
            print(f"could not open {path}: {err}")
        img = resize_img(raw_img)
        img = ImageTk.PhotoImage(img)
        img = Img(str(path),img)
        Imgs.append(img)

    return Imgs

def main():
    global canvas

    root= tk.Tk()
    root.geometry("1000x400")
    root.title("Display Image App")

    images = getimages()

    #Create A Main Frame
    main_frame = Frame(root)
    main_frame.pack(fill=BOTH,expand=1)
    #Create A Canvas
    canvas = Canvas(main_frame)
    canvas.pack(side=LEFT, fill=BOTH, expand=1)

    #Add A Scrollbar to the Canvas
    scrollbar = Scrollbar(main_frame, orient=VERTICAL, command=canvas.yview)
    scrollbar.pack(side=RIGHT,fill=Y)

    #Configure Canvas
    canvas.configure(yscrollcommand=scrollbar.set)
    canvas.bind('<Configure>',  event)

    #Create another Frame inside the Canvas
    imageframe = Frame(canvas)

    #add that new frame to a window in the canvas
    canvas.create_window((0,0), window=imageframe, anchor="nw")

    num_images = len(images)
    num_images_per_col = 4
    num_rows = ceil(num_images/num_images_per_col)


    for i in range(num_images_per_col):
        imageframe.columnconfigure(i, weight=1)


    index=0
    for i in range(num_rows):
        for j in range(num_images_per_col):
            if index > num_images-1:
                break
            img = images[index].img
            button_img = Button(imageframe,image=img, command=command,borderwidth=2)
            button_img.grid(row=i,column=j, pady=5)
            index += 1

    root.mainloop()

main()
Answered By: jsbueno

The easiest solution is to use the -jump option, this alters the mechanic used between Scrollbar and Canvas. Instead of trying to respond and redraw for every message the user might sent through interacting with the Scrollbar, the final position will be used to adjust the View.

scrollbar = Scrollbar(main_frame, orient=VERTICAL, command=canvas.yview, jump=True)

If you don’t like this simple fix, you will need code your own Scrollbar which isn’t too hard with and find the right approach for your specific case, it might include the fraction option and moveto instead of scrolling.

Answered By: Thingamabobs

So I’ve come across a work around that works for me where instead of overlaying an image on the button, I draw the images directly on the canvas and then bind a function associated with a click on that image to provide the same functionality. It’s not perfect but works for my needs. Thanks to everyone who originally answered.


import os
import json
import tkinter as tk
from tkinter import ttk
from math import *
from tkinter import *
from PIL import ImageTk, Image
import customtkinter as ctk

directory = "IMAGE DIR HERE!" # <-- CHANGE FOR YOUR SYSTEM!
class Img: 
    def __init__(self,name=-2,img=-2):
        self.name = name
        self.img = img


def event(args):
    canvas.configure(scrollregion = canvas.bbox("all"))


def on_image_click(event,name): 
    #Do Stuff
    print(name)



def resize_img(raw_img):
    std_size = (300,300) 
    img = raw_img.resize(std_size)
    return img



def getimages(): 
   
    images = os.listdir(directory); 

    Imgs=[]
    exts = ["gif","jpg","png"]
    for name in images:
        ext = name.split(".")[1]
        print(ext)
        if ext not in exts: 
            print("False")
            continue        
        print("True")
        raw_img = Image.open(directory + "/"+name)
        img = resize_img(raw_img)
        img = ImageTk.PhotoImage(img) 
        img = Img(name,img)
        Imgs.append(img)
    
    return Imgs

root= ctk.CTk()
root.geometry("1000x400")
root.title("Display Image App")


images = getimages()



#Create A Main Frame
main_frame = Frame(root)
main_frame.pack(fill=BOTH,expand=1)


#Create A Canvas
canvas = Canvas(main_frame)
canvas.pack(side=LEFT, fill=BOTH, expand=1) 

#Add A Scrollbar to the Canvas 
scrollbar = ctk.CTkScrollbar(main_frame, command=canvas.yview)
scrollbar.pack(side=RIGHT,fill=Y)

#Configure Canvas
canvas.configure(yscrollcommand=scrollbar.set)
canvas.bind('<Configure>',  event)

#Create another Frame inside the Canvas
imageframe = Frame(canvas)

#add that new frame to a window in the canvas 
canvas.create_window((0,0), window=imageframe, anchor="nw")



num_images = len(images)
num_images_per_col = 4
num_rows = ceil(num_images/num_images_per_col)


for i in range(num_images_per_col): 
    imageframe.columnconfigure(i, weight=1)


index=0
for i in range(num_rows): 
    for j in range(num_images_per_col): 
        if index > num_images-1: 
            break
        image = images[index]
        img = image.img
        name = image.name
        x = j * 320 
        y = i * 320  
        img_id = canvas.create_image(x, y, anchor="nw", image=img)
        canvas.tag_bind(img_id, '<Button-1>', lambda event, name=name: on_image_click(event,name))

        index += 1



root.mainloop()
Answered By: Michael