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()
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()
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 tkinter and find the right approach for your specific case, it might include the fraction
option and moveto
instead of scrolling.
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()
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()
[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()
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 tkinter and find the right approach for your specific case, it might include the fraction
option and moveto
instead of scrolling.
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()