Python and Gtk code to reduce file size and resize images for web usage.
I needed a tool for my wife to resize images for her blog and I found one called Trimage. I wasn’t much fond of it so I wrote my own, it’s fast and stable for only a days worth of coding. To run the code directly you’ll need GObject and PIL, you might want Pillow over PIL. A download exists below for a binary with all dependencies included if you just want to use it like any other application without any trouble.
Get the App for Linux
Download a binary with all the dependencies.
Download Size: 102.7mb (yes it’s big but it contains all the depends!)
SHA256: 323d117f2f1f75327df702e74a4f3b92b85210e6aff1029cd2b9ec2acce76b35
Download: http://www.darkartistry.com/Public/Squeasy.tar.xz
Features
Features bulk image load or individual image selection, user preferred settings, quick preview of loaded images, META data removal, resize width or height keeping aspect ratio or set both width and height for custom size, optimize setting, and size reduction setting so you can choose the best quality for your needs.
Learning
Some things you can learn from this code: Python Threading, saving binary user data in pickle, Gtk Liststore, and Treeview row events. It was fun to write. Screenshots follow the code.
Compiling
Alternatively, to freeze as a binary with all the depends you can install pyinstaller with PIP and issue this command while in the same folder as squeasy.py: pyinstaller squeasy.py
You may be able to make a binary for Windows with py2exe and OS X using py2app. I have also had great results on OS X using Platypus in the past. To get Gtk on Windows and OS X see this page.
Source Code
#!/usr/bin/env python3 import gi gi.require_version('Gtk', '3.0') from gi.repository import Gtk, Gio, GObject, GdkPixbuf from concurrent.futures import ThreadPoolExecutor from PIL import Image import os import pickle import getpass ''' Author: Charles Nichols Date: February 24, 2020 ''' image_types = { 0:"jpg", 1:"png", 2:"gif" } def get_user(): user = getpass.getuser() if not user: user = 'user_name_here' return user class ImageResizer: def __init__(self): self.output = '' self.file_type = 'jpg' self.quality = 80 self.optimize = True self.img_w = None self.img_h = None self.user_w = 0 self.user_h = 0 def get_aspect_ratio(self): ratio = None if self.user_h > 0 and self.user_w > 0: # Set custom size. ratio = (self.user_h, self.user_w) elif self.user_h > 0: width = float(self.user_h) / float(self.img_w) ratio = (self.user_h,int(float(self.img_h) * float(width))) elif self.user_w > 0: height = float(self.user_w) / float(self.img_h) ratio = (int(float(self.img_w) * float(height)),self.user_w) return ratio def set_output_path(self, image_path): head,tail = os.path.split(image_path) filename = os.path.splitext(tail)[0] filename = '{0}.{1}'.format(filename,self.file_type) return os.path.join(self.output,filename) def process_image(self, image_path): if not os.path.exists(image_path): raise Warning('Image not found.') try: img = Image.open(image_path) self.img_w,self.img_h=img.size aspect = self.get_aspect_ratio() if aspect: img = img.resize(aspect,Image.ANTIALIAS) output_path = self.set_output_path(image_path) if self.file_type.lower() == 'jpg' and img.mode.upper() == 'RGBA': img = img.convert('RGB') img.save(output_path,optimize=self.optimize,quality=self.quality) yield os.path.getsize(output_path),"Done",image_path except: yield 0,'Failed',image_path def save_settings(d, path='settings.dat'): if d: p = pickle.dumps(d) with open(path,'wb') as fh: fh.write(p) def open_settings(path='settings.dat'): if os.path.exists(path): with open('settings.dat','rb') as fh: d = fh.read() return pickle.loads(d) class AppWindow(Gtk.Window): def __init__(self): Gtk.Window.__init__(self, title="Squeasy") self.set_border_width(10) self.set_default_size(1024, 640) self.set_icon_name("utilities-file-archiver") self.output_path = '/home/{0}/squeasy_images'.format(get_user()) self.width = 0 self.height = 0 self.quality = 90 self.optimize = True self.type = 0 # 0 = jpg and the default. self.settings = open_settings() if not self.settings: self.settings = {} if 'w' not in self.settings: self.settings['w'] = self.width if 'h' not in self.settings: self.settings['h'] = self.height if 'q' not in self.settings: self.settings['q'] = self.quality if 'o' not in self.settings: self.settings['o'] = self.optimize if 't' not in self.settings: self.settings['t'] = self.type if 'p' not in self.settings: self.settings['p'] = self.output_path self.width = self.settings['w'] self.height = self.settings['h'] self.quality = self.settings['q'] self.optimize = self.settings['o'] self.type = self.settings['t'] self.output_path = self.settings['p'] # ===================================================================== # Header bar # ===================================================================== header = Gtk.HeaderBar(title="Squeasy") header.props.show_close_button = True # Add buttons to header to display file and folder dialogs, left side. add_file_button = Gtk.Button() icon_add_file = Gio.ThemedIcon(name="add") image_add_file = Gtk.Image.new_from_gicon(icon_add_file, Gtk.IconSize.BUTTON) add_file_button.add(image_add_file) add_file_button.set_tooltip_text("Select file(s)") add_file_button.connect("clicked", self.on_file_clicked) header.pack_start(add_file_button) add_folder_button = Gtk.Button() icon_add_folder = Gio.ThemedIcon(name="folder") image_add_folder = Gtk.Image.new_from_gicon(icon_add_folder, Gtk.IconSize.BUTTON) add_folder_button.add(image_add_folder) add_folder_button.set_tooltip_text("Select folder") add_folder_button.connect("clicked", self.on_folder_clicked) header.pack_start(add_folder_button) clear_button = Gtk.Button() icon_clear = Gio.ThemedIcon(name="clean-up") image_clear = Gtk.Image.new_from_gicon(icon_clear, Gtk.IconSize.BUTTON) clear_button.add(image_clear) clear_button.set_tooltip_text("Clear List") clear_button.connect("clicked", self.on_clear_list) header.pack_start(clear_button) exec_button = Gtk.Button() icon_exec = Gio.ThemedIcon(name="start") image_exec = Gtk.Image.new_from_gicon(icon_exec, Gtk.IconSize.BUTTON) exec_button.add(image_exec) exec_button.set_tooltip_text("Process images") exec_button.connect("clicked", self.on_exec_clicked) header.pack_start(exec_button) # Menu button, right side. self.settings_button = Gtk.Button() icon_settings = Gio.ThemedIcon(name="open-menu") image_settings= Gtk.Image.new_from_gicon(icon_settings, Gtk.IconSize.BUTTON) self.settings_button.add(image_settings) self.settings_button.set_tooltip_text("Settings") self.settings_button.connect("clicked", self.on_settings_clicked) header.pack_end(self.settings_button) # ===================================================================== # Drop menu for settings. # ===================================================================== # Button to exit main window. exit_button = Gtk.Button() icon_exit = Gio.ThemedIcon(name="stock_exit") image_exit = Gtk.Image.new_from_gicon(icon_exit, Gtk.IconSize.BUTTON) exit_button.add(image_exit) exit_button.set_tooltip_text("Exit") exit_button.connect("clicked", self.on_exit_clicked) # Button to save settings save_button = Gtk.Button() icon_save = Gio.ThemedIcon(name="stock_save") image_save = Gtk.Image.new_from_gicon(icon_save, Gtk.IconSize.BUTTON) save_button.add(image_save) save_button.set_tooltip_text("Save settings") save_button.connect("clicked", self.on_save_clicked) about_button = Gtk.Button() icon_about= Gio.ThemedIcon(name="help-about") image_about = Gtk.Image.new_from_gicon(icon_about, Gtk.IconSize.BUTTON) about_button.add(image_about) about_button.set_tooltip_text("About") about_button.connect("clicked", self.on_about_clicked) hbox_width = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) hbox_height = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) hbox_quality = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) hbox_optimize = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) hbox_type = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) hbox_output = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) hbox_save = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) hbox_about = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) hbox_exit = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) width_label = Gtk.Label() width_label.set_text("New Width") height_label = Gtk.Label() height_label.set_text("New Height") quality_label = Gtk.Label() quality_label.set_text("Quality") optimized_label = Gtk.Label() optimized_label.set_text("Optimize") type_label = Gtk.Label() type_label.set_text("Output Type") output_label = Gtk.Label() output_label.set_text("Output Path") save_label = Gtk.Label() save_label.set_text("Save Settings") about_label = Gtk.Label() about_label.set_text("About Squeasy") exit_label = Gtk.Label() exit_label.set_text("Exit Squeasy") adjustment_w = Gtk.Adjustment() adjustment_w.configure(self.width, 0, 5000, 1, 10, 0) hbox_width.pack_start(width_label, False, True, 10) self.width_spin = Gtk.SpinButton() self.width_spin.set_adjustment(adjustment_w) hbox_width.pack_end(self.width_spin, False, True, 10) adjustment_h = Gtk.Adjustment() adjustment_h.configure(self.height, 0, 5000, 1, 10, 0) hbox_height.pack_start(height_label, False, True, 10) self.height_spin = Gtk.SpinButton() self.height_spin.set_adjustment(adjustment_h) hbox_height.pack_end(self.height_spin, False, True, 10) adjustment = Gtk.Adjustment() adjustment.configure(self.quality, 0, 100, 1, 10, 0) hbox_quality.pack_start(quality_label, False, True, 10) self.quality_spin = Gtk.SpinButton() self.quality_spin.set_adjustment(adjustment) hbox_quality.pack_end(self.quality_spin, False, True, 10) hbox_optimize.pack_start(optimized_label, False, True, 10) self.opt_switch = Gtk.Switch() self.opt_switch.set_active(self.optimize) hbox_optimize.pack_end(self.opt_switch, False, True, 10) hbox_type.pack_start(type_label, False, True, 10) type_store = Gtk.ListStore(str) type_store.append(["JPG"]) type_store.append(["PNG"]) type_store.append(["GIF"]) self.type_drop = Gtk.ComboBox.new_with_model(type_store) renderer_text = Gtk.CellRendererText() self.type_drop.pack_start(renderer_text, True) self.type_drop.add_attribute(renderer_text, "text", 0) self.type_drop.set_active(self.type) vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) hbox_type.pack_end(self.type_drop, False, True, 10) hbox_output.pack_start(output_label, False, True, 10) self.out_entry = Gtk.Entry() self.out_entry.set_text(self.output_path) hbox_output.pack_end(self.out_entry, False, True, 10) hbox_about.pack_start(about_label, False, True, 10) hbox_about.pack_end(about_button, False, True, 10) hbox_save.pack_start(save_label, False, True, 10) hbox_save.pack_end(save_button, False, True, 10) hbox_exit.pack_start(exit_label, False, True, 10) hbox_exit.pack_end(exit_button, False, True, 10) # Drop menu for settings. self.popover = Gtk.Popover() vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) vbox.pack_start(hbox_width, False, True, 10) vbox.pack_start(hbox_height, False, True, 10) vbox.pack_start(hbox_quality, False, True, 10) vbox.pack_start(hbox_optimize, False, True, 10) vbox.pack_start(hbox_type, False, True, 10) vbox.pack_start(hbox_output, False, True, 10) vbox.pack_start(hbox_save, False, True, 10) vbox.pack_start(hbox_about, False, True, 10) vbox.pack_start(hbox_exit, False, True, 10) self.popover.add(vbox) self.popover.set_position(Gtk.PositionType.BOTTOM) # ===================================================================== # List images being processed # ===================================================================== self.store = Gtk.ListStore(str, int, int, str) list_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) self.add(list_box) self.image_list = Gtk.TreeView() self.image_list.columns_autosize() self.image_list.set_model(self.store) rendererText = Gtk.CellRendererText() self.column_pth = Gtk.TreeViewColumn("Image Path", rendererText, text=0) self.column_pth.set_expand(True) self.column_osz = Gtk.TreeViewColumn("Old Size", rendererText, text=1) self.column_nsz = Gtk.TreeViewColumn("New Size", rendererText, text=2) self.column_sts = Gtk.TreeViewColumn("Status", rendererText, text=3) self.column_osz.set_sizing(Gtk.TreeViewColumnSizing.FIXED) self.column_osz.set_fixed_width(100) self.column_osz.set_min_width(100) self.column_osz.set_expand(False) self.column_nsz.set_sizing(Gtk.TreeViewColumnSizing.FIXED) self.column_nsz.set_fixed_width(100) self.column_nsz.set_min_width(100) self.column_nsz.set_expand(False) self.column_sts.set_sizing(Gtk.TreeViewColumnSizing.FIXED) self.column_sts.set_fixed_width(100) self.column_sts.set_min_width(100) self.column_sts.set_expand(False) self.image_list.append_column(self.column_pth) self.image_list.append_column(self.column_osz) self.image_list.append_column(self.column_nsz) self.image_list.append_column(self.column_sts) self.image_list.connect('button-press-event', self.row_selected_event) list_box.pack_start(self.image_list, True, True, 0) self.set_titlebar(header) self.show_all() def row_selected_event(self, selection, event): model = selection.get_model() path_info = selection.get_path_at_pos(int(event.x), int(event.y)) if path_info != None: pth, col, cellx, celly = path_info selection.grab_focus() selection.set_cursor( pth, col, 0) if event.button == 3: # right click dialog = Gtk.MessageDialog(self, 0, Gtk.MessageType.WARNING, Gtk.ButtonsType.OK_CANCEL, "Remove the selected image from list?") response = dialog.run() if response == Gtk.ResponseType.OK: iter = model.get_iter(pth) model.remove(iter) dialog.destroy() else: image = Gtk.Image() path = model[pth][0] try: dialog = Gtk.Dialog(self) dialog.set_title("Preview") pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(path, width=600, height=500, preserve_aspect_ratio=True) preview_img = Gtk.Image () preview_img.set_from_pixbuf(pixbuf) content_area = dialog.get_content_area() content_area.add(preview_img) dialog.add_button(button_text="OK", response_id=Gtk.ResponseType.OK) dialog.show_all() dialog.run() dialog.destroy() except: pass def on_save_clicked(self, widget): flag_save = False self.width = self.width_spin.get_value() self.height = self.height_spin.get_value() if self.opt_switch.get_active(): self.optimize = True else: self.optimize = False self.quality = self.quality_spin.get_value() self.type = self.type_drop.get_active() self.output_path = self.out_entry.get_text() if self.settings['w'] != self.width: flag_save = True self.settings['w'] = self.width if self.settings['h'] != self.height: flag_save = True self.settings['h'] = self.height if self.settings['q'] != self.quality: flag_save = True self.settings['q'] = self.quality if self.settings['o'] != self.optimize: flag_save = True self.settings['o'] = self.optimize if self.settings['t'] != self.type: flag_save = True self.settings['t'] = self.type if self.settings['p'] != self.output_path : flag_save = True self.settings['p'] = self.output_path if flag_save: save_settings(self.settings) if not os.path.exists(self.output_path): os.makedirs(self.output_path) flag_save = False self.popover.hide() def on_about_clicked(self, widget): dialog = Gtk.MessageDialog(self, 0, Gtk.MessageType.INFO, Gtk.ButtonsType.OK, "Squeasy, the easy image optimizer and resizer!") dialog.format_secondary_text('Developed by Charles Nichols, Feb. 2020.\nhttps://www.darkartistry.com/') dialog.run() dialog.destroy() self.popover.hide() def on_exec_clicked(self, widget): if len(self.store) == 0: dialog = Gtk.MessageDialog(self, 0, Gtk.MessageType.INFO, Gtk.ButtonsType.OK, "You need to select at least one image.") dialog.run() dialog.destroy() return if not os.path.exists(self.output_path): try: os.path.makedirs(self.output_path) except: dialog = Gtk.MessageDialog(self, 0, Gtk.MessageType.INFO, Gtk.ButtonsType.OK, "Unable to create output folder, see settings.") dialog.run() dialog.destroy() return imgr=ImageResizer() imgr.output = self.output_path imgr.file_type = image_types[self.type].strip().lower() imgr.quality = int(self.quality) imgr.optimize = self.optimize imgr.user_w = int(self.width) imgr.user_h = int(self.height) for task in self.store: try: with ThreadPoolExecutor(max_workers=25) as executor: worker = executor.submit(imgr.process_image,task[0]) for t in worker.result(): if task[0] == t[-1]: task[-1]=t[1] # status task[-2]=t[0] # size except: if task[0] == t[-1]: task[-1]="Failed" task[-2]=0 self.image_list.show_all() def on_file_clicked(self, widget): dialog = Gtk.FileChooserDialog("Please choose a file", self, Gtk.FileChooserAction.OPEN, (Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, Gtk.STOCK_OPEN, Gtk.ResponseType.OK)) self.add_filters(dialog) response = dialog.run() if response == Gtk.ResponseType.OK: image_path = dialog.get_filename() size = os.path.getsize(image_path) self.store.append([image_path,size,0,"Queued"]) self.image_list.show_all() self.image_list.show_all() dialog.destroy() def add_filters(self, dialog): filter_img = Gtk.FileFilter() filter_img.set_name("Images") filter_img.add_pattern("*.jpg") filter_img.add_pattern("*.png") filter_img.add_pattern("*.gif") dialog.add_filter(filter_img) def on_folder_clicked(self, widget): self.store.clear() dialog = Gtk.FileChooserDialog("Please choose a folder", self, Gtk.FileChooserAction.SELECT_FOLDER, (Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, "Select", Gtk.ResponseType.OK)) dialog.set_default_size(800, 400) response = dialog.run() if response == Gtk.ResponseType.OK: folder_path = dialog.get_filename() for img in os.listdir(folder_path): if os.path.splitext(img)[-1].strip().lower() not in ['.jpg','.png','.gif']: continue image_path = os.path.join(folder_path, img) size = os.path.getsize(image_path) self.store.append([image_path,size,0,"Queued"]) self.image_list.show_all() dialog.destroy() else: dialog.destroy() def on_clear_list(self, widget): self.store.clear() def on_settings_clicked(self, widget): self.popover.set_relative_to(self.settings_button) self.popover.show_all() self.popover.popup() def on_exit_clicked(self, widget): self.destroy() if __name__ == "__main__": win = AppWindow() win.connect("destroy", Gtk.main_quit) win.show_all() Gtk.main()