Skip to content

Instantly share code, notes, and snippets.

@righthandabacus
Created November 18, 2023 04:15
Show Gist options
  • Save righthandabacus/97205ae658bcf1184d38ba65f2a19120 to your computer and use it in GitHub Desktop.
Save righthandabacus/97205ae658bcf1184d38ba65f2a19120 to your computer and use it in GitHub Desktop.
Tkinter-based image browser
import argparse
import tkinter as tk
import PIL.Image
import PIL.ImageTk
class ImageBrowser:
"""Image browser using tkinter, two images are displayed sid-eby-side, with
aspect ratio preserved, and allows to navigate using left/right arrow keys
or buttons. Close window or press "q" to quit.
"""
def __init__(self, master: tk.Tk):
self.started = False # prevent unnecessary update before mainloop start
self.width = 800 # remember the updated window size by resize event
self.height = 600
# main window
self.master = master
self.master.title("Image Browser")
self.master.geometry(f"{self.width}x{self.height}")
# image frame to hold two canvas
self.image_frame = tk.Frame(self.master, relief="ridge",
highlightbackground="blue", highlightthickness=2)
self.image_frame.grid(column=0, row=0, columnspan=2, sticky="nsew")
# navigation buttons: prev and next, and hook to event handlers
self.previous_button = tk.Button(self.master, text="Previous", command=self.previous_image)
self.previous_button.grid(column=0, row=1, sticky="nsw", padx=10, pady=10)
self.next_button = tk.Button(self.master, text="Next", command=self.next_image)
self.next_button.grid(column=1, row=1, sticky="nse", padx=10, pady=10)
# status bar to display image information
self.status_bar = tk.Label(self.master, text="", relief="groove")
self.status_bar.grid(column=0, row=2, columnspan=2, sticky="nsew")
# two canvas to hold the images
self.left_canvas = tk.Canvas(self.image_frame)
self.left_canvas.grid(row=0, column=0, padx=10, pady=10, sticky="nesw")
self.right_canvas = tk.Canvas(self.image_frame)
self.right_canvas.grid(row=0, column=1, padx=10, pady=10, sticky="nesw")
# tooltip widget: hidden by default
# here the hide/show is using window withdraw() and deiconify(); label pack(ipadx=1) and
# pack_forget() are not necessary
self.tooltip_win = tk.Toplevel(self.image_frame)
self.tooltip_win.wm_overrideredirect(1)
self.tooltip_label = tk.Label(self.tooltip_win, justify=tk.LEFT, background="#ffffe0",
font=("Arial", "8", "normal"))
self.tooltip_label.pack(ipadx=1)
self.tooltip_win.withdraw() # hide the window
self.tooltip_win.hidden = True # remember tooltip visibility
# some widgets should resizes with the window
self.image_frame.grid_rowconfigure(0, weight=1)
self.image_frame.grid_columnconfigure(0, weight=1)
self.image_frame.grid_columnconfigure(1, weight=1)
self.master.grid_rowconfigure(0, weight=1)
self.master.grid_columnconfigure(0, weight=1)
# Bind tooltip events: use <Motion> instead of <Enter> to catch movement within canvas
self.left_canvas.bind("<Motion>", self.tooltip_on)
self.left_canvas.bind("<Leave>", self.tooltip_off)
self.right_canvas.bind("<Motion>", self.tooltip_on)
self.right_canvas.bind("<Leave>", self.tooltip_off)
# Bind keyboard events
self.set_keyevents({
"<Left>": self.previous_image,
"<Right>": self.next_image,
"q": self.quit
})
# Bind resize event: note that event hooked to root object will be triggered by everything
self.master.bind("<Configure>", self.resize_window)
# Initialize the image index
self.image_files = []
self.image_index = 0
def tooltip_on(self, event):
"""Display tooltip. This function also set the message displayed"""
# set message on label
canvas = event.widget
x = int(canvas.canvasx(event.x))
y = int(canvas.canvasx(event.y))
if canvas == self.left_canvas:
message = f"Left image coordinate ({x},{y})"
elif event.widget == self.right_canvas:
message = f"Right image coordinate ({x},{y})"
self.tooltip_label.config(text=message)
# move tooltip window to cursor
x, y = self.image_frame.winfo_pointerxy()
self.tooltip_win.wm_geometry(f"+{x+10}+{y+10}")
if self.tooltip_win.hidden:
self.tooltip_win.deiconify() # show the window
self.tooltip_win.hidden = False
self.tooltip_win.lift() # move tooltip window to top in case focus changed
def tooltip_off(self, event):
"""Hide the tooltip window"""
self.tooltip_win.withdraw() # hide the window
self.tooltip_win.hidden = True
def set_keyevents(self, table):
"""Set keypress events according to the provided dict. Also set up the help window
Args:
table: A dict of key string-function mapping. Help is retreived from the function's
doc string
"""
helps = {"?": "Show help"} # to remember for display when help key is pressed
for key, function in table.items():
helps[key] = function.__doc__
self.master.bind(key, function)
self.master.bind("?", self.show_help)
self.key_help = helps
self.helpwin = None
def show_help(self, event, modal=False):
if self.helpwin is not None:
return # help window exists, ignore
keylen = max(len(key) for key in self.key_help)
message = "\n".join(key.ljust(keylen+1) + msg for key, msg in self.key_help.items())
# create modal window
self.helpwin = tk.Toplevel(self.master)
self.helpwin.title("Keyboard help")
self.helpwin.resizable(False, False) # trick to show as separate window in macOS
label = tk.Label(self.helpwin, text=message, justify=tk.LEFT,
font=("Courier", "10", "normal"))
label.pack(padx=10, pady=10, expand=1)
def release(event=None):
self.helpwin.grab_release()
self.helpwin.destroy()
self.helpwin = None
button = tk.Button(self.helpwin, text="OK", command=release)
button.pack(pady=10)
self.helpwin.geometry("300x200")
self.helpwin.wait_visibility()
self.helpwin.protocol("WM_DELETE_WINDOW", release)
self.helpwin.bind("<Escape>", release)
if modal:
self.helpwin.grab_set()
self.helpwin.transient(self.master)
def set_images(self, filelist):
"""remember image filenames, should be invoked before mainloop start"""
self.image_files[:] = list(filelist)
self.image_index = 0
def load_images(self, resize=False):
"""Load the image as indicated by self.image_index
"""
self.started = True # toggle on first load
# Check if there are enough images for both sides
if len(self.image_files) < 2:
self.status_bar.config(text="Less than two images to display")
return
# Load the left image, resize, clear the canvas, and put image on canvas
self.left_canvas.delete("all")
if not resize:
image = PIL.Image.open(self.image_files[self.image_index])
self.left_canvas.image = image
else:
image = self.left_canvas.image
origsize, newsize = draw_image_to_canvas(image, self.left_canvas)
message = [f"[{self.image_index+1}/{len(self.image_files)}] "
f"{self.image_files[self.image_index]} {origsize}->{newsize}"]
# Load the right image, resize, clear the canvas, and put image on canvas
self.right_canvas.delete("all")
if not resize:
if self.image_index + 1 < len(self.image_files):
image = PIL.Image.open(self.image_files[self.image_index + 1])
self.right_canvas.image = image
else:
self.right_canvas.image = None
if self.right_canvas.image:
origsize, newsize = draw_image_to_canvas(self.right_canvas.image, self.right_canvas)
message += [f"and [{self.image_index+2}/{len(self.image_files)}] "
f"{self.image_files[self.image_index+1]} {origsize}->{newsize}"]
# Update the status bar with the current file names and sizes
self.status_bar.config(text=" ".join(message))
def resize_window(self, event=None):
"""Event handler for all config events, but only take care of resizing of main window"""
if event.widget != self.master:
return # only hook to window resize event at top object
if event.width == self.width and event.height == self.height:
return # no change in size, maybe just moving the window
# window resized, do reload
self.width, self.height = event.width, event.height
if self.started:
self.load_images(resize=True)
def quit(self, event=None):
"""Quit the application"""
self.tooltip_win.destroy()
self.master.destroy()
def previous_image(self, event=None):
"""Previous image"""
if self.image_index > 0:
self.image_index -= 1
self.load_images()
def next_image(self, event=None):
"""Next image"""
if self.image_index < len(self.image_files) - 1:
self.image_index += 1
self.load_images()
def aspect_locked_resize(orig_size, target_size):
"""Calculate the aspect-preserved resize dimension and the offset to make the resized bbox
center on the target canvas
"""
w_old, h_old = orig_size
w_max, h_max = target_size
ratio = min(w_max/w_old, h_max/h_old)
w_new, h_new = int(w_old*ratio), int(h_old*ratio)
w0 = (target_size[0] - w_new) // 2
h0 = (target_size[1] - h_new) // 2
return (w_new, h_new), (w0, h0)
def draw_image_to_canvas(image: PIL.Image, canvas: tk.Canvas):
"""Draw the PIL image onto the canvas at (0,0) corner. Resize to fit the
canvas if appropriate
Returns:
two (width,height) tuples for the original image size and the resized size
"""
origsize = image.size
targetsize = (canvas.winfo_width(), canvas.winfo_height())
newsize, pos = aspect_locked_resize(origsize, targetsize)
tkimg = PIL.ImageTk.PhotoImage(image.resize(newsize))
canvas.tkimg = tkimg # tk bug: hold image to avoid GC
canvas.create_image(pos[0], pos[1], image=tkimg, anchor="nw")
return origsize, newsize
def main():
parser = argparse.ArgumentParser("Image browser")
parser.add_argument("file", nargs="+")
args = parser.parse_args()
root = tk.Tk()
image_browser = ImageBrowser(root)
image_browser.set_images(args.file)
# wait until mainloop started, so geometry is calculated, then display images
root.after(0, image_browser.load_images)
root.mainloop()
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment