From ef77b97d65448f65c98bb356cf01d214679c77c6 Mon Sep 17 00:00:00 2001 From: David Stirling Date: Wed, 5 May 2021 23:57:40 +0000 Subject: [PATCH] Transfer current version to GitHub --- imquick.iss | 60 +++ imquick.py | 683 +++++++++++++++++++++++++++++++++++ requirements.txt | 5 + resources/ActualSize.png | Bin 0 -> 192 bytes resources/Brightness.png | Bin 0 -> 382 bytes resources/BrightnessAuto.png | Bin 0 -> 617 bytes resources/FitWindow.png | Bin 0 -> 249 bytes resources/ImQuick.ico | Bin 0 -> 109184 bytes resources/Left.png | Bin 0 -> 204 bytes resources/Minus.png | Bin 0 -> 144 bytes resources/OpenFile.png | Bin 0 -> 271 bytes resources/Plus.png | Bin 0 -> 182 bytes resources/Right.png | Bin 0 -> 199 bytes 13 files changed, 748 insertions(+) create mode 100644 imquick.iss create mode 100644 imquick.py create mode 100644 requirements.txt create mode 100644 resources/ActualSize.png create mode 100644 resources/Brightness.png create mode 100644 resources/BrightnessAuto.png create mode 100644 resources/FitWindow.png create mode 100644 resources/ImQuick.ico create mode 100644 resources/Left.png create mode 100644 resources/Minus.png create mode 100644 resources/OpenFile.png create mode 100644 resources/Plus.png create mode 100644 resources/Right.png diff --git a/imquick.iss b/imquick.iss new file mode 100644 index 0000000..c500d86 --- /dev/null +++ b/imquick.iss @@ -0,0 +1,60 @@ +; Script generated by the Inno Setup Script Wizard. +; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES! + +#define MyAppName "ImQuick" +#define MyAppVersion "0.5 Beta" +#define MyAppPublisher "David Stirling" +#define MyAppURL "https://github.com/DavidStirling/ImQuick" +#define MyAppExeName "ImQuick.exe" + +[Setup] +; NOTE: The value of AppId uniquely identifies this application. Do not use the same AppId value in installers for other applications. +; (To generate a new GUID, click Tools | Generate GUID inside the IDE.) +AppId={{298804AE-3FE3-4D4D-A79E-9925FE61A7CD} +AppName={#MyAppName} +AppVersion={#MyAppVersion} +;AppVerName={#MyAppName} {#MyAppVersion} +AppPublisher={#MyAppPublisher} +AppPublisherURL={#MyAppURL} +AppSupportURL={#MyAppURL} +AppUpdatesURL={#MyAppURL} +DefaultDirName={pf64}\{#MyAppName} +DisableProgramGroupPage=yes +DisableDirPage=no +OutputBaseFilename=ImQuick_setup +SetupIconFile=resources\ImQuick.ico +UninstallDisplayIcon={app}\resources\ImQuick.ico +LicenseFile=LICENSE +Compression=lzma +SolidCompression=yes +ChangesAssociations = yes +WizardStyle=modern + +[Languages] +Name: "english"; MessagesFile: "compiler:Default.isl" + +[Tasks] +Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked + +[Files] +Source: "ImQuick.dist\{#MyAppExeName}"; DestDir: "{app}"; Flags: ignoreversion +Source: "ImQuick.dist\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs +; NOTE: Don't use "Flags: ignoreversion" on any shared system files + +[Registry] +Root: HKCR; Subkey: "{#MyAppName}"; ValueData: "Program {#MyAppName}"; Flags: uninsdeletekey; ValueType: string; ValueName: "" +Root: HKCR; Subkey: "{#MyAppName}\DefaultIcon"; ValueData: "{app}\{#MyAppExeName},0"; ValueType: string; ValueName: "" +Root: HKCR; Subkey: "{#MyAppName}\shell\open\command"; ValueData: """{app}\{#MyAppExeName}"" ""%1"""; ValueType: string; ValueName: "" + +Root: HKCR; Subkey: "SystemFileAssociations\image\shell\ImQuick"; ValueType: none; ValueName: ""; ValueData: ""; Flags: uninsdeletekey +Root: HKCR; Subkey: "SystemFileAssociations\image\shell\ImQuick"; ValueType: string; ValueName: "Icon"; ValueData: "{app}\resources\ImQuick.ico"; Flags: uninsdeletekey +Root: HKCR; Subkey: "SystemFileAssociations\image\shell\ImQuick\command"; ValueType: string; ValueName: ""; ValueData: """{app}\{#MyAppExeName}"" ""%1"""; Flags: uninsdeletekey + + +[Icons] +Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}" +Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon + +[Run] +Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent + diff --git a/imquick.py b/imquick.py new file mode 100644 index 0000000..b113246 --- /dev/null +++ b/imquick.py @@ -0,0 +1,683 @@ +# ImQuick - A lightweight scientific image viewer. +# Copyright(C) 2021 David Stirling +# Canvas pan/zoom code adapted from https://stackoverflow.com/questions/41656176/tkinter-canvas-zoom-move-pan +# Drag/drop utilises the tkdnd2 extension https://github.com/petasis/tkdnd +# and wrapper https://sourceforge.net/projects/tkinterdnd/ + +import math +import imageio +import os +import re +import sys +import numpy as np +from PIL import Image, ImageTk +import tkinter as tk +import tkinter.ttk as ttk +import tkinter.filedialog as tkfiledialog +import TkinterDnD2 as tkDnD + +__version__ = "0.5 Beta" + +SUPPORTED_EXTENSIONS = {".tif", ".tiff", ".gif", ".png", ".jpeg", ".jpg", ".bmp", ".npz", ".itk"} +ICON_FILE = 'ImQuick.ico' + + +def not_without_file(func): + # Decorator to only run a function when a file is already loaded. Arg 0 = self. + def wrapper(*args, **kwargs): + if args[0].file: + func(*args, **kwargs) + return wrapper + + +class HideyScrollBar(ttk.Scrollbar): + # Scrollbars which auto-hide when not needed. + def set(self, mini, maxi): + if float(mini) <= 0.0 and float(maxi) >= 1.0: + self.grid_remove() + else: + self.grid() + ttk.Scrollbar.set(self, mini, maxi) + + +class ImQuick(tk.Toplevel): + # Main GUI window + def __init__(self, master, filename=r""): + super(ImQuick, self).__init__() + self.master = master + self.title(f"ImQuick {__version__}") + self.iconbitmap(resource_directory(ICON_FILE)) + self.geometry(f"500x500") + self.file = None + self.file_list = [] + self.current_index = 0 + self.image_data = None + self.scaled_image_data = None + self.displayed_image = None + self.display = None + self.zoom_factor = 1 + self.delta = 1.3 + self.width = 0 + self.height = 0 + self.container = None + self.info_popup = None + self.display_popup = None + self.about_popup = None + + self.min_display_value = tk.IntVar(self, value=0) + self.max_display_value = tk.IntVar(self, value=255) + + self.min_display_value.trace("w", self.update_min_display) + self.max_display_value.trace("w", self.update_max_display) + + style = ttk.Style() + style.configure('mini.TButton', justify='center', width=3, height=1, state='!disabled') + + self.menubar = self.create_menus() + + self.config(menu=self.menubar) + + self.image_frame = ttk.Frame(self) + self.canvas = tk.Canvas(self.image_frame) + h = HideyScrollBar(self.image_frame, orient=tk.HORIZONTAL) + v = HideyScrollBar(self.image_frame, orient=tk.VERTICAL) + v.configure(command=self.scroll_y) # bind scrollbars to the canvas + h.configure(command=self.scroll_x) + self.canvas.config(xscrollcommand=h.set, yscrollcommand=v.set) + + self.canvas.bind("", self.hover_pixel) + self.bind("", self.no_pixel) + + self.canvas.bind('', self.show_image) # canvas is resized + self.canvas.bind('', self.move_from) + self.canvas.bind('', self.move_to) + self.canvas.bind('', self.zoom_mouse) + self.protocol('WM_DELETE_WINDOW', self.close) + + # PyCharm will complain, but these binds are needed for drag and drop. + self.drop_target_register(tkDnD.DND_FILES) + self.dnd_bind('<>', self.on_drop) + + self.xyvalue = tk.StringVar(value="-") + self.pixelvalue = tk.StringVar(value="-") + + self.statusbar = ttk.Frame(self, borderwidth=1) + + self.open_button = ttk.Button(self.statusbar, style='mini.TButton', text="O", command=self.open_file) + self.prev_button = ttk.Button(self.statusbar, style='mini.TButton', text="<", command=self.prev_file) + self.next_button = ttk.Button(self.statusbar, style='mini.TButton', text=">", command=self.next_file) + + self.zoomout_button = ttk.Button(self.statusbar, style='mini.TButton', text="-", command=self.zoom_out) + self.zoomin_button = ttk.Button(self.statusbar, style='mini.TButton', text="+", command=self.zoom_in) + + self.zoomfull_button = ttk.Button(self.statusbar, style='mini.TButton', text="r", command=self.first_show_image) + self.zoomfit_button = ttk.Button(self.statusbar, style='mini.TButton', text="f", command=self.fit_to_window) + + self.autocontrast_button = ttk.Button(self.statusbar, style='mini.TButton', text="", command=self.auto_contrast) + self.contrast_button = ttk.Button(self.statusbar, style='mini.TButton', text="c", command=self.adjust_contrast) + + self.open_icon = tk.PhotoImage(file=resource_directory("OpenFile.png")) + self.zoomin_icon = tk.PhotoImage(file=resource_directory("Plus.png")) + self.zoomout_icon = tk.PhotoImage(file=resource_directory("Minus.png")) + self.next_icon = tk.PhotoImage(file=resource_directory("Right.png")) + self.prev_icon = tk.PhotoImage(file=resource_directory("Left.png")) + self.full_icon = tk.PhotoImage(file=resource_directory("ActualSize.png")) + self.fit_icon = tk.PhotoImage(file=resource_directory("FitWindow.png")) + self.contrast_icon = tk.PhotoImage(file=resource_directory("Brightness.png")) + self.autocontrast_icon = tk.PhotoImage(file=resource_directory("BrightnessAuto.png")) + + self.open_button.config(image=self.open_icon) + self.zoomin_button.config(image=self.zoomin_icon) + self.zoomout_button.config(image=self.zoomout_icon) + self.next_button.config(image=self.next_icon) + self.prev_button.config(image=self.prev_icon) + self.zoomfull_button.config(image=self.full_icon) + self.zoomfit_button.config(image=self.fit_icon) + self.contrast_button.config(image=self.contrast_icon) + self.autocontrast_button.config(image=self.autocontrast_icon) + + self.statusseparator = ttk.Separator(self.statusbar, orient='vertical') + + self.status_xy = ttk.Label(self.statusbar, textvariable=self.xyvalue, width=15, anchor=tk.CENTER) + self.status_pixel = ttk.Label(self.statusbar, textvariable=self.pixelvalue, width=20, anchor=tk.CENTER) + + self.open_button.pack(side=tk.LEFT, padx=(3, 0)) + self.prev_button.pack(side=tk.LEFT, padx=(3, 0)) + self.next_button.pack(side=tk.LEFT, padx=(0, 3)) + self.zoomout_button.pack(side=tk.LEFT, padx=(3, 0)) + self.zoomin_button.pack(side=tk.LEFT, padx=(0, 3)) + self.zoomfull_button.pack(side=tk.LEFT, padx=(3, 3)) + self.zoomfit_button.pack(side=tk.LEFT, padx=(3, 3)) + self.contrast_button.pack(side=tk.LEFT, padx=(3, 0)) + self.autocontrast_button.pack(side=tk.LEFT, padx=(0, 3)) + + self.status_pixel.pack(side=tk.RIGHT, fill=tk.X) + self.statusseparator.pack(side=tk.RIGHT, fill=tk.BOTH) + self.status_xy.pack(side=tk.RIGHT, fill=tk.X) + + self.statusbar.pack(fill=tk.X) + self.image_frame.pack(fill=tk.BOTH, expand=True) + + self.image_frame.rowconfigure(0, weight=1) + self.image_frame.columnconfigure(0, weight=1) + v.grid(row=0, column=1, sticky='ns') + h.grid(row=1, column=0, sticky='we') + self.canvas.grid(row=0, column=0, sticky='nswe') + + self.canvas.update() + + if filename: + self.load_image(filename) + else: + self.canvas.create_text(self.canvas.winfo_width() // 2, self.canvas.winfo_height() // 2, + anchor=tk.CENTER, text="[Drag a file here to open]") + + def on_drop(self, event): + # Open files dropped onto the window + line = event.data + to_open = [] + for obj in re.findall('{.*?}', line): + to_open.append(obj[1:-1]) + line = line.replace(obj, "") + to_open += line.split(" ") + for file in to_open: + if os.path.splitext(file)[-1].lower() in SUPPORTED_EXTENSIONS: + if self.file: + ImQuick(self.master, file) + else: + self.load_image(file) + + def create_menus(self): + # Create the menu bar. + menubar = tk.Menu(self) + menu_file = tk.Menu(menubar, tearoff=False) + menu_view = tk.Menu(menubar, tearoff=False) + menu_help = tk.Menu(menubar, tearoff=False) + + menu_file.add_command(label='Open Image', command=self.open_file) + menu_file.add_separator() + menu_file.add_command(label='Previous Image', command=self.prev_file) + menu_file.add_command(label='Next Image', command=self.next_file) + menu_file.add_separator() + menu_file.add_command(label='Close', command=self.close) + + menu_view.add_command(label='Show information', command=self.get_info) + menu_view.add_command(label='Adjust brightness', command=self.adjust_contrast) + + menu_help.add_command(label='Documentation', command=docs) + menu_help.add_command(label='About ImQuick', command=self.about) + + menubar.add_cascade(menu=menu_file, label='File') + menubar.add_cascade(menu=menu_view, label='View') + menubar.add_cascade(menu=menu_help, label='Help') + return menubar + + def update_min_display(self, *args): + # Set minimum displayed pixel intensity + min_d = self.min_display_value.get() + max_d = self.max_display_value.get() + if min_d == 255: + self.min_display_value.set(254) + if min_d >= max_d: + self.max_display_value.set(min_d + 1) + self.update_contrast() + + def update_max_display(self, *args): + # Set maximum displayed pixel intensity + min_d = self.min_display_value.get() + max_d = self.max_display_value.get() + if max_d == 0: + self.max_display_value.set(1) + if min_d >= max_d: + self.min_display_value.set(max_d - 1) + self.update_contrast() + + def update_contrast(self, *args): + # Apply pixel min/max intensity display + new_min = self.min_display_value.get() + new_max = self.max_display_value.get() + temp_data = self.scaled_image_data.copy() + temp_data[temp_data < new_min] = new_min + temp_data = ((temp_data - new_min) / (new_max - new_min)) + temp_data[temp_data > 1] = 1 + self.displayed_image = Image.fromarray((temp_data * 255).astype('uint8')) + self.show_image() + + def about(self): + # Show 'about' dialog + if self.about_popup: + self.about_popup.lift() + else: + self.about_popup = AboutPopup(self) + + @not_without_file + def get_info(self): + # Show image information dialog + if self.info_popup: + self.info_popup.lift() + else: + self.info_popup = InfoPopup(self, self.image_data, self.file) + + @not_without_file + def adjust_contrast(self): + # Show contrast adjustment dialog + if self.display_popup: + self.display_popup.lift() + else: + self.display_popup = DisplayPopup(self, self.file) + + @not_without_file + def auto_contrast(self): + # Set contrast range to min-max pixel intensity values. + self.min_display_value.set(self.scaled_image_data.min()) + self.max_display_value.set(self.scaled_image_data.max()) + + def load_image(self, file): + # Open an image + self.canvas.delete("all") + file = os.path.normpath(file) + try: + reader = imageio.get_reader(file) + self.image_data = reader.get_data(0) + except: + self.canvas.create_text(self.canvas.winfo_width() // 2, self.canvas.winfo_height() // 2, + anchor=tk.CENTER, text="[Unable to open file]") + self.file = None + return + maxval = self.image_data.max() + if maxval >= 4096: + self.scaled_image_data = self.image_data / 265 + elif maxval >= 1024: + self.scaled_image_data = self.image_data / 16 + elif maxval >= 256: + self.scaled_image_data = self.image_data / 4 + elif maxval <= 1: + self.scaled_image_data = self.image_data * 256 + else: + self.scaled_image_data = self.image_data + self.scaled_image_data = self.scaled_image_data.astype('uint8') + self.displayed_image = Image.fromarray(self.scaled_image_data) + self.width, self.height = self.displayed_image.size + self.file = file + if self.info_popup is not None: + self.info_popup.show_info(self.image_data, file) + if self.display_popup is not None: + self.display_popup.show_info(file) + self.first_show_image(loading=True) + self.title(f"ImQuick {__version__} - {'...' + file[-100:] if len(file) > 100 else file}") + if self.info_popup: + self.info_popup.show_info(self.image_data, file) + + @not_without_file + def scroll_y(self, *args, **kwargs): + # Scroll the canvas in the y axis + self.canvas.yview(*args, **kwargs) + self.show_image() + + @not_without_file + def scroll_x(self, *args, **kwargs): + # Scroll the canvas in the x axis + self.canvas.xview(*args, **kwargs) + self.show_image() + + @not_without_file + def move_from(self, event): + # Set starting position for mouse drag + self.canvas.scan_mark(event.x, event.y) + + @not_without_file + def move_to(self, event): + # Move canvas to target position when dragging + self.canvas.scan_dragto(event.x, event.y, gain=1) + self.show_image() + + @not_without_file + def zoom_in(self): + # Zoom in, towards the center of the canvas + i = min(self.canvas.winfo_width(), self.canvas.winfo_height()) + if i/5 < self.zoom_factor: + return + self.zoom_factor *= self.delta + scale = self.delta + self.zoom_image(self.canvas.winfo_width() / 2, self.canvas.winfo_height() / 2, scale) + + @not_without_file + def zoom_out(self): + # Zoom in, from the center of the canvas + i = min(self.width, self.height) + if int(i * self.zoom_factor) < 30: + return + self.zoom_factor /= self.delta + scale = 1 / self.delta + self.zoom_image(self.canvas.winfo_width() / 2, self.canvas.winfo_height() / 2, scale) + + @not_without_file + def zoom_mouse(self, event): + # Zoom relative to the mouse cursor + x = self.canvas.canvasx(event.x) + y = self.canvas.canvasy(event.y) + bbox = self.canvas.bbox(self.container) + if not bbox[0] < x < bbox[2] or not bbox[1] < y < bbox[3]: + return + scale = 1.0 + if event.delta < 0: + i = min(self.width, self.height) + if int(i * self.zoom_factor) < 30: + return + self.zoom_factor /= self.delta + scale = 1 / self.delta + if event.delta > 0: + i = min(self.canvas.winfo_width(), self.canvas.winfo_height()) + if i/5 < self.zoom_factor: + return + self.zoom_factor *= self.delta + scale = self.delta + self.zoom_image(x, y, scale) + + @not_without_file + def zoom_image(self, x, y, scale): + # Apply a zoom transform + self.canvas.scale(self.container, x, y, scale, scale) # Resize the bounding box too + self.show_image() + + @not_without_file + def first_show_image(self, event=None, loading=False): + # Setup display of an image for the first time, (or reset pan/zoom). + init_x = (self.canvas.winfo_width() // 2) - (self.width // 2) + init_y = (self.canvas.winfo_height() // 2) - (self.height // 2) + if loading and (self.width > self.canvas.winfo_width() or self.height > self.canvas.winfo_height()): + self.fit_to_window() + else: + self.zoom_factor = 1 + self.container = self.canvas.create_rectangle(init_x, init_y, init_x + self.width, init_y + self.height, + width=0) + self.canvas.imagetk = ImageTk.PhotoImage(self.displayed_image) + self.canvas.create_image(self.canvas.winfo_width() // 2, self.canvas.winfo_height() // 2, + anchor=tk.CENTER, image=self.canvas.imagetk) + self.center_canvas() + + def center_canvas(self): + # Move the canvas postion back to the center. + init_x = (self.canvas.winfo_width() // 2) - (self.width // 2) + init_y = (self.canvas.winfo_height() // 2) - (self.height // 2) + tgt_x = int(self.canvas.canvasx(init_x)) + tgt_y = int(self.canvas.canvasy(init_y)) + if init_x != tgt_x or init_y != tgt_y: + self.canvas.scan_mark(init_x, init_y) + self.canvas.scan_dragto(tgt_x, tgt_y, gain=1) + + @not_without_file + def fit_to_window(self, event=None): + # Resize the image to fit the window + self.center_canvas() + init_x = (self.canvas.winfo_width() // 2) - (self.width // 2) + init_y = (self.canvas.winfo_height() // 2) - (self.height // 2) + scale = min((self.canvas.winfo_width() - 4) / self.width, (self.canvas.winfo_height() - 4) / self.height) + self.zoom_factor = scale + + self.container = self.canvas.create_rectangle(init_x, init_y, init_x + self.width, init_y + self.height, + width=0) + self.zoom_image(self.canvas.winfo_width() / 2, self.canvas.winfo_height() / 2, scale) + + def show_image(self, event=None): + # Update display of the image on the canvas + if not self.container: + return + image_bbox = self.canvas.bbox(self.container) # get image area + # Remove 1 pixel shift at the sides of the image_bbox + image_bbox = (image_bbox[0] + 1, image_bbox[1] + 1, image_bbox[2] - 1, image_bbox[3] - 1) + visible_bbox = (self.canvas.canvasx(0), # get visible area of the canvas + self.canvas.canvasy(0), + self.canvas.canvasx(self.canvas.winfo_width()), + self.canvas.canvasy(self.canvas.winfo_height())) + scroll_bbox = [min(image_bbox[0], visible_bbox[0]), min(image_bbox[1], visible_bbox[1]), + max(image_bbox[2], visible_bbox[2]), max(image_bbox[3], visible_bbox[3])] + if scroll_bbox[0] == visible_bbox[0] and scroll_bbox[2] == visible_bbox[2]: # whole image in the visible area + scroll_bbox[0] = image_bbox[0] + scroll_bbox[2] = image_bbox[2] + if scroll_bbox[1] == visible_bbox[1] and scroll_bbox[3] == visible_bbox[3]: # whole image in the visible area + scroll_bbox[1] = image_bbox[1] + scroll_bbox[3] = image_bbox[3] + self.canvas.configure(scrollregion=scroll_bbox) # set scroll region + x1 = max(visible_bbox[0] - image_bbox[0], 0) # get coordinates (x1,y1,x2,y2) of the image tile + y1 = max(visible_bbox[1] - image_bbox[1], 0) + x2 = min(visible_bbox[2], image_bbox[2]) - image_bbox[0] + y2 = min(visible_bbox[3], image_bbox[3]) - image_bbox[1] + if int(x2 - x1) > 0 and int(y2 - y1) > 0: # show image if it in the visible area + # Desired bbox in the original pixel size + des_x1 = x1 / self.zoom_factor + des_x2 = x2 / self.zoom_factor + des_y1 = y1 / self.zoom_factor + des_y2 = y2 / self.zoom_factor + des_x_width = int(x2 - x1) + des_y_height = int(y2 - y1) + + real_x_width = des_x2 - des_x1 + rnd_x_width = math.ceil(des_x2) - math.floor(des_x1) + tgt_x_width = (des_x_width / real_x_width) * rnd_x_width + + real_y_height = des_y2 - des_y1 + rnd_y_height = math.ceil(des_y2) - math.floor(des_y1) + tgt_y_height = (des_y_height / real_y_height) * rnd_y_height + + x = des_x_width / rnd_x_width * (des_x1 - math.floor(des_x1)) + y = des_y_height / rnd_y_height * (des_y1 - math.floor(des_y1)) + + # First crop to target area, with a whole-pixel border. Scale up, then crop further to the desired subpixels + image = self.displayed_image.crop((math.floor(des_x1), math.floor(des_y1), + math.ceil(des_x2), math.ceil(des_y2))) + image = image.resize((int(tgt_x_width), int(tgt_y_height)), resample=Image.NEAREST) + image = image.crop((x, y, x + des_x_width, y + des_y_height)) + self.canvas.imagetk = ImageTk.PhotoImage(image) + + self.canvas.create_image(max(visible_bbox[0], image_bbox[0]), max(visible_bbox[1], image_bbox[1]), + anchor='nw', image=self.canvas.imagetk) + + def make_file_list(self): + # Scan the current directory for supported image files. + directory = os.path.dirname(os.path.abspath(self.file)) + self.file_list = [os.path.normpath(os.path.join(directory, file)) for file in os.listdir(directory) if + os.path.splitext(file)[-1] in SUPPORTED_EXTENSIONS] + self.current_index = self.file_list.index(self.file) + + def open_file(self): + # Open a file specified with a file dialog + file = tkfiledialog.askopenfilename() + if file: + self.file_list = [] + self.load_image(os.path.normpath(file)) + + @not_without_file + def next_file(self): + # Open the next file in the current directory + if len(self.file_list) == 0: + self.make_file_list() + if self.current_index < len(self.file_list) - 1: + self.zoom_factor = 1 + self.current_index += 1 + self.load_image(self.file_list[self.current_index]) + + @not_without_file + def prev_file(self): + # Open the previous file in the current directory + if len(self.file_list) == 0: + self.make_file_list() + if self.current_index > 0: + self.zoom_factor = 1 + self.current_index -= 1 + self.load_image(self.file_list[self.current_index]) + + def hover_pixel(self, event): + # Display pixel coordinate and value under the mouse pointer. + if self.file and self.displayed_image: + dx, dy, _, _ = self.canvas.bbox(self.container) + event.x = int((self.canvas.canvasx(event.x) - dx) / self.zoom_factor) + event.y = int((self.canvas.canvasy(event.y) - dy) / self.zoom_factor) + if 0 <= event.y < self.image_data.shape[0] and 0 <= event.x < self.image_data.shape[1]: + pixel = self.image_data[event.y][event.x] # Correct for border around label. + self.xyvalue.set(f"X: {event.x} Y: {event.y}") + self.pixelvalue.set(pixel) + return "break" + self.xyvalue.set("-") + self.pixelvalue.set("-") + return "break" + + def no_pixel(self, event): + # Clear pixel display when not hovering over the image. + self.xyvalue.set("-") + self.pixelvalue.set("-") + + def close(self, event=None): + # Close the window + # The central window manager may have other windows open, so we need to explicitly close any children. + if self.info_popup: + self.info_popup.destroy() + if self.display_popup: + self.display_popup.destroy() + if self.about_popup: + self.about_popup.destroy() + self.destroy() + if not self.master.children: + # Shut down ImQuick if no other windows are open. + self.master.destroy() + + +class InfoPopup(tk.Toplevel): + # Dialog showing image statistics + def __init__(self, master, data, file): + super(InfoPopup, self).__init__() + self.master = master + self.filename = ttk.Label(self, text="Info") + self.title("Image details") + self.iconbitmap(resource_directory(ICON_FILE)) + self.transient(master) + + self.info = tk.Text(self) + + self.filename.pack() + self.info.pack(fill=tk.BOTH, expand=True) + self.show_info(data, file) + self.protocol('WM_DELETE_WINDOW', self.destroy) + self.geometry(f"250x150+{master.winfo_x() + master.winfo_width()}+{master.winfo_y()}") + + def destroy(self): + super(InfoPopup, self).destroy() + self.master.info_popup = None + # Deregister from the main window manager too. + if self._name in self.master.master.children: + del self.master.master.children[self._name] + + def show_info(self, data, file): + filename = os.path.split(file)[-1] + self.filename.config(text=filename) + infotxt = f""" + Format: {data.dtype} + Width: {data.shape[1]}px + Height: {data.shape[0]}px + Minimum: {data.min()} + Maximum: {data.max()} + Unique Values: {len(np.unique(data))} + """ + self.info.configure(state=tk.NORMAL) + self.info.delete(1.0, tk.END) + self.info.insert(tk.INSERT, infotxt) + self.info.configure(state=tk.DISABLED) + + +class DisplayPopup(tk.Toplevel): + # Dialog for contrast adjustment + def __init__(self, master, file): + super(DisplayPopup, self).__init__() + self.master = master + self.filename = ttk.Label(self, text="Info", anchor=tk.CENTER) + self.title(f"Adjust Contrast") + self.iconbitmap(resource_directory(ICON_FILE)) + self.resizable(0, 0) + self.transient(master) + + self.min_slider = ttk.Scale(self, variable=master.min_display_value, from_=0, to=255) + + self.max_slider = ttk.Scale(self, variable=master.max_display_value, from_=0, to=255) + + self.columnconfigure(1, weight=3) + self.rowconfigure(1, weight=1) + self.rowconfigure(2, weight=1) + self.filename.grid(column=0, row=0, columnspan=2, sticky=tk.NSEW, pady=5) + ttk.Label(self, text="Min:").grid(column=0, row=1, sticky=tk.NSEW, padx=5) + ttk.Label(self, text="Max:").grid(column=0, row=2, sticky=tk.NSEW, padx=5) + self.min_slider.grid(column=1, row=1, sticky=tk.NSEW, padx=10) + self.max_slider.grid(column=1, row=2, sticky=tk.NSEW, padx=10) + + self.show_info(file) + self.protocol('WM_DELETE_WINDOW', self.destroy) + self.geometry(f"200x150+{master.winfo_x() + master.winfo_width()}+{master.winfo_y()}") + + def destroy(self): + super(DisplayPopup, self).destroy() + self.master.display_popup = None + # Deregister from the main window manager too. + if self._name in self.master.master.children: + del self.master.master.children[self._name] + + def show_info(self, file): + filename = os.path.split(file)[-1] + self.filename.config(text=filename) + + +class AboutPopup(tk.Toplevel): + # Dialog for info about ImQuick + def __init__(self, master): + super(AboutPopup, self).__init__() + self.master = master + self.title("About ImQuick") + self.resizable(0, 0) + self.transient(master) + self.iconbitmap(resource_directory(ICON_FILE)) + self.logo = Image.open(resource_directory(ICON_FILE)).resize((100, 100)) + self.logoimg = ImageTk.PhotoImage(self.logo) + tk.Label(self, image=self.logoimg).pack(pady=(15, 0)) + tk.Label(self, text="ImQuick", font=("Arial", 18), justify=tk.CENTER).pack() + tk.Label(self, text="Version " + __version__, font=("Consolas", 10), justify=tk.CENTER).pack(pady=(0, 5)) + tk.Label(self, text="David Stirling, 2021", font=("Arial", 10), justify=tk.CENTER).pack() + tk.Label(self, text="@DavidRStirling", font=("Arial", 10), justify=tk.CENTER).pack(pady=(0, 15)) + self.protocol('WM_DELETE_WINDOW', self.destroy) + self.geometry(f"250x250+{master.winfo_x() + master.winfo_width()}+{master.winfo_y()}") + + def destroy(self): + super(AboutPopup, self).destroy() + self.master.about_popup = None + # Deregister from the main window manager too. + if self._name in self.master.master.children: + del self.master.master.children[self._name] + + +def docs(): + import webbrowser + webbrowser.open("https://github.com/DavidStirling/ImQuick") + + +def _load_tkdnd(master): + # Loads the DND plugin from the packed-in directory + master.tk.eval('global auto_path; lappend auto_path {tkdnd}') + master.tk.eval('package require tkdnd') + master._tkdnd_loaded = True + + +def resource_directory(target): + if '__compiled__' in globals(): + return os.path.join(sys.prefix, 'resources', target) + else: + return os.path.join('resources', target) + + +if __name__ == '__main__': + if len(sys.argv) > 1: + file_in = sys.argv[1] + else: + file_in = '' + root = tkDnD.TkinterDnD.Tk() + root.wm_title("I am the window manager. If you can see me, something isn't right") + root.withdraw() + _load_tkdnd(root) + app = ImQuick(root, file_in) + root.mainloop() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..d0c67a2 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +imageio +imagetools +Nuitka +numpy +Pillow \ No newline at end of file diff --git a/resources/ActualSize.png b/resources/ActualSize.png new file mode 100644 index 0000000000000000000000000000000000000000..4925bcd4d01af7f421fb6db237c2c1c95b5d380b GIT binary patch literal 192 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61SBU+%rFB|jKx9jP7LeL$-D$|SkfJR9T^xl z_H+M9WCij$3p^r=85k7JL734vVty4+Fx1n0__-e?^%l2j}LAoTpSc z6K*kx9Ms}aS?I@ft92!lq=U$W$11m~XXQyX^fen4Fz#TQl*_<)CV;6cL6l{Z2+QmF h_Vo*NvY6P|7-D8>E3a)>zaMA;gQu&X%Q~loCIIQIGz0(u literal 0 HcmV?d00001 diff --git a/resources/Brightness.png b/resources/Brightness.png new file mode 100644 index 0000000000000000000000000000000000000000..b87485fa7e85673a77ce86e32d1ea6df0bc66297 GIT binary patch literal 382 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`jKx9jP7LeL$-D$|SkfJR9T^xl z_H+M9WCij$3p^r=85k7JK$!8NeC<4-24PPZ#}EturIT&_m<Px#1ZP1_K>z@;j|==^1poj532;bRa{vG#H2?r1H33YfT=M_`0scuuK~y+TrIS6Y zT0szoC-I1Q@v4XjcJTuh5f$$h4;KE81PMs0l(+I9q_eO$g_SAOY9)4phJcD7hzd$j zRB{s{F(hR2&T`Hn=7SV@;W_Ntot>GToqa&RO9MK%1vfGQy#Yhe2j67kZ+ZmY0yfwi znE$et0G58v;4yd!zJu#@I!(H+lihBo>2ylnZdYvXffGCjO_?wykw1ZVfE^A8SF2TC zuh+cUY`EEMve)Z1vk=ZeW%3ZnnO_5m90U}NMk$lYkl*hoY@}o|Nx583mP~yHuYe{F zq+nTa4U|%;6eSV~noK6t>-8uY3{tUJG**?oK=$b3K&oxY>2#{-;c%EnqmgnTndI|% z^7(v*m8Dy8AXjxzzfdF+QPKDNJ@xy2n$2dk+wCY63Q;yIK`qIi8*y+UF_P%#^I177 z6bf`Yom3X3QpvEo)E!XOMfzN;)%bWkDi(_cFPBR`91aX8+-|pjy^T8?K5`4s0L9~R zDwoUT@px!37|?h;COA8d%r@B{&N_B8!1q z%|Mv(p?vK;pkR=vi(`ny<>UtDZ$$|HT?bo Oa*L;{pUXO@geCx;C`BRw literal 0 HcmV?d00001 diff --git a/resources/ImQuick.ico b/resources/ImQuick.ico new file mode 100644 index 0000000000000000000000000000000000000000..98d429bc18583327a71ab6b552b0f55ee20d3cc3 GIT binary patch literal 109184 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 LgU*+&ooD|K%+e=j literal 0 HcmV?d00001 diff --git a/resources/Left.png b/resources/Left.png new file mode 100644 index 0000000000000000000000000000000000000000..a27b40addd3d0201e41275147446e5f40da37e14 GIT binary patch literal 204 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61SBU+%rFB|Y$ZW{!3_UF&^$uA7AV45;1OBO zz`%C|gc+x5^GO2*b39!fLo_B%ophS_fC7(m?vbjQ(-`kEPjy(^I6?Y^excozmiP%< z{xfuiMz3V|*!HYg`oRjefbdsSH*VkQlgA*aQ(4+yw6sn3HRA-4jnaEo798yno-zC8 vhh+XqX={Y@Q|0Pm7iMP9ggTe~DWM4f7I7;C literal 0 HcmV?d00001 diff --git a/resources/OpenFile.png b/resources/OpenFile.png new file mode 100644 index 0000000000000000000000000000000000000000..6dec6c71b61d8e21bc4bb7b318167e48de36f0ec GIT binary patch literal 271 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`jKx9jP7LeL$-D$|SkfJR9T^xl z_H+M9WCij$3p^r=85k7JK$!8NeC<4-;1W+4#}Etuvy;5}4jXW|6u$J`<(YRy`J2bh zFABX`%Nh!oXq-A8_tjHlbHXu;HmQ?QD?OALO1NuVd#)e;wLi-HgpyywEaz1(e~SJ6 zaFsJGO<8T@>k5XlResaI9GTEM<3Zjn^%-h8UK^L~)xOremof2%0nhUN9(>Gw3@yhd z&YoB3>UH#%Y{1XS47XRr**F}Tw}+oUA#K)k{-(*PJX91?}qH8`nI==X{+}rLo_B%o#e>Jpvb}8{jqxAHTF&HX0{&9hXSpp37xsS@YI=< zY!B0!avQd%FJn?MUYega)QEyPp|;3WbkzLb6Mw<&;$SkvqTgC literal 0 HcmV?d00001