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()