Created
November 18, 2023 04:15
-
-
Save righthandabacus/97205ae658bcf1184d38ba65f2a19120 to your computer and use it in GitHub Desktop.
Tkinter-based image browser
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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