Skip to content

Instantly share code, notes, and snippets.

@vdcrim
Created October 24, 2014 15:36
Show Gist options
  • Save vdcrim/ca909861ec1b77029c37 to your computer and use it in GitHub Desktop.
Save vdcrim/ca909861ec1b77029c37 to your computer and use it in GitHub Desktop.
Optimize image files
#!/usr/bin/env python3
"""
Optimize image files
- Convert losslessly compressed files to PNG
- Optimize PNG and JPEG compression
- Optionally, convert PNG to JPEG when it would result in an
output/input size ratio below a given value
- Optionally, recompress JPEG files above a given quality level
If a file(s) or directory is not specified, all the files in the
current working directory are processed.
Requirements (Python):
- Python 3.3+
- pywin32 (Windows, to keep also creation date, optional)
Requirements (CLI applications):
- ImageMagick (identify, mogrify, convert)
- mozjpeg (jpegtran)
- OptiPNG (optipng)
Copyright (C) 2014 Diego Fernández Gosende <dfgosende@gmail.com>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License along
with this program. If not, see <http://www.gnu.org/licenses/gpl-3.0.html>.
"""
version = '0.1.0'
# DEFAULTS
class settings:
# Paths to applications
identify = r'identify'
mogrify = r'mogrify'
convert = r'convert'
jpegtran = r'jpegtran'
optipng = r'optipng'
# Show files processed and output/input size ratio
verbose = False
# Include subdirectories
recursive = False
# JPEG quality level to be used on compression
jpeg_q = 90
# Recompress JPEG files of at least this quality
recompress_jpeg = False
recompress_jpeg_q_threshold = 95
# Convert PNG to JPEG if the size ratio is below the following
convert_to_jpeg = False
convert_to_jpeg_ratio_threshold = 0.25
# List of file extensions, these files will be converted to PNG
# (or to JPEG according to above). Currently must be suported
# by OptiPNG.
optimize_types = '.bmp', '.tif', '.tiff', '.pnm'
# ------------------------------------------------------------------------------
import sys
import builtins
import os
import subprocess
def print(*args, **kwargs):
"""Replace characters that can't be printed to console"""
builtins.print(*(str(arg).encode(sys.stdout.encoding, 'backslashreplace')
.decode(sys.stdout.encoding)
for arg in args), **kwargs)
try:
import win32file, win32con
except ImportError as err:
pywin32_err = str(err)
def get_file_times(filename):
stat = os.stat(filename)
return stat.st_ctime_ns, stat.st_atime_ns, stat.st_mtime_ns
def set_file_times(filename, times):
os.utime(filename, ns=times[1:])
else:
pywin32_err = None
def get_file_times(filename):
filehandle = win32file.CreateFileW(
filename,
win32con.GENERIC_READ,
win32con.FILE_SHARE_READ | win32con.FILE_SHARE_WRITE \
| win32con.FILE_SHARE_DELETE,
None,
win32con.OPEN_EXISTING,
win32con.FILE_FLAG_BACKUP_SEMANTICS,
None)
c, a, m = win32file.GetFileTime(filehandle)
filehandle.close()
return c, a, m
def set_file_times(filename, times):
filehandle = win32file.CreateFileW(
filename,
win32con.GENERIC_WRITE,
win32con.FILE_SHARE_READ | win32con.FILE_SHARE_WRITE \
| win32con.FILE_SHARE_DELETE,
None,
win32con.OPEN_EXISTING,
win32con.FILE_FLAG_BACKUP_SEMANTICS,
None)
win32file.SetFileTime(filehandle, *times)
filehandle.close()
def _optimize_jpeg(file, recompress, q, q_threshold):
basename = os.path.basename(file)
if recompress:
ret = subprocess.check_output([settings.identify, '-format', '%Q ',
file])
ret = ret.decode(sys.stdout.encoding).strip()
if settings.verbose:
print(' {}, Q {}'.format(basename, ret))
if int(ret) >= q_threshold:
if settings.verbose:
print(' reducing quality to {}'.format(q))
subprocess.check_call([settings.mogrify, '-quality', str(q), file])
elif settings.verbose:
print(' ' + basename)
temp_file = os.path.splitext(basename)[0] + '.tmp'
subprocess.check_call([settings.jpegtran, '-progressive', '-copy', 'all',
'-outfile', temp_file, file])
os.remove(file)
os.rename(temp_file, file)
return file
def _optimize_png(file, convert_to_jpeg, convert_to_jpeg_ratio_threshold,
jpeg_q):
basename = os.path.basename(file)
if settings.verbose:
print(' ' + basename)
subprocess.check_call([settings.optipng, '-quiet', '-preserve', '-clobber',
'-out', file, file])
if convert_to_jpeg:
base = os.path.splitext(basename)[0]
temp_file = base + '.tmp'
subprocess.check_call([settings.convert, file, '-quality', str(jpeg_q),
'jpg:' + temp_file])
jpeg_png_ratio = os.path.getsize(temp_file) / os.path.getsize(file)
if jpeg_png_ratio < convert_to_jpeg_ratio_threshold:
if settings.verbose:
print(' converting to JPEG')
subprocess.check_call([settings.jpegtran, '-progressive', '-copy',
'all', '-outfile', base + '.jpg', temp_file])
os.remove(file)
file = base + '.jpg'
elif settings.verbose:
print(' JPEG/PNG ratio {:.2%}, keeping PNG'.format(jpeg_png_ratio))
os.remove(temp_file)
return file
def _optimize_other(file, convert_to_jpeg, convert_to_jpeg_ratio_threshold,
jpeg_q):
basename = os.path.basename(file)
if settings.verbose:
print(' {}\n converting to PNG'.format(basename))
output_file = os.path.splitext(basename)[0] + '.png'
subprocess.check_call([settings.optipng, '-quiet', '-preserve',
'-clobber', '-out', output_file, file])
os.remove(file)
if convert_to_jpeg:
output_file = _optimize_png(output_file, convert_to_jpeg,
convert_to_jpeg_ratio_threshold, jpeg_q)
return output_file
def optimize_image(file, convert_to_jpeg=None,
convert_to_jpeg_ratio_threshold=None,
recompress_jpeg=None,
recompress_jpeg_q_threshold=None, jpeg_q=None):
if convert_to_jpeg is None:
convert_to_jpeg = settings.convert_to_jpeg
if convert_to_jpeg_ratio_threshold is None:
convert_to_jpeg_ratio_threshold = \
settings.convert_to_jpeg_ratio_threshold
if recompress_jpeg is None:
recompress_jpeg = settings.recompress_jpeg
if recompress_jpeg_q_threshold is None:
recompress_jpeg_q_threshold = settings.recompress_jpeg_q_threshold
if jpeg_q is None:
jpeg_q = settings.jpeg_q
ext = os.path.splitext(file)[1]
input_size_file = os.path.getsize(file)
times = get_file_times(file)
if ext in ('.jpg', '.jpeg'):
file = _optimize_jpeg(file,
recompress_jpeg,
jpeg_q,
recompress_jpeg_q_threshold)
elif ext == '.png':
file = _optimize_png(file,
convert_to_jpeg,
convert_to_jpeg_ratio_threshold,
jpeg_q)
else:
file = _optimize_other(file,
convert_to_jpeg,
convert_to_jpeg_ratio_threshold,
jpeg_q)
set_file_times(file, times)
output_size_file = os.path.getsize(file)
if settings.verbose:
if output_size_file == input_size_file:
print(' file unchanged')
else:
print(' {:.2%}'.format(output_size_file / input_size_file))
return file, output_size_file, input_size_file
def optimize_directory(directory, recursive=None, convert_to_jpeg=None,
convert_to_jpeg_ratio_threshold=None,
recompress_jpeg=None, recompress_jpeg_q_threshold=None,
jpeg_q=None):
if recursive is None:
recursive = settings.recursive
processed_files = changed_files = input_size = output_size = 0
for dirpath, dirnames, filenames in os.walk(directory):
if settings.verbose:
old_processed_files = processed_files
print('Processing images on directory\n {}\n'.format(dirpath))
for filename in (f for f in filenames if os.path.splitext(f)[1]
in settings.optimize_types):
_, output_size_file, input_size_file = \
optimize_image(os.path.join(dirpath, filename),
convert_to_jpeg,
convert_to_jpeg_ratio_threshold,
recompress_jpeg,
recompress_jpeg_q_threshold,
jpeg_q)
processed_files += 1
if output_size_file != input_size_file:
changed_files += 1
output_size += output_size_file
input_size += input_size_file
if settings.verbose and processed_files != old_processed_files:
print('')
if not recursive:
break
return processed_files, changed_files, output_size, input_size
settings.optimize_types = set(settings.optimize_types).union(['.png', '.jpg'])
if __name__ == '__main__':
import argparse
import shutil
import atexit
# Check arguments as paths. Added 'directory' and 'executable' keywords
class CheckPathAction(argparse.Action):
def __init__(self, option_strings, dest, **kwargs):
self.is_directory = kwargs.pop('directory', None)
self.is_executable = kwargs.pop('executable', False)
if self.is_executable:
self.is_directory = False
argparse.Action.__init__(self, option_strings, dest, **kwargs)
def __call__(self, parser, namespace, values, option_string=None):
if self.is_directory is None:
path_type = 'path'
path_exists = os.path.exists
elif self.is_directory:
path_type = 'directory'
path_exists = os.path.isdir
else:
path_type = 'file'
if self.is_executable:
path_exists = shutil.which
else:
path_exists = os.path.isfile
if isinstance(values, str):
values = os.path.expandvars(os.path.expanduser(values))
if not path_exists(values):
parser.error('the parameter passed is not a {}\n {}\n'
.format(path_type, values))
else:
for i, path in enumerate(values):
path = os.path.expandvars(os.path.expanduser(path))
if not path_exists(path):
parser.error('the parameter passed is not a {}\n {}\n'
.format(path_type, path))
values[i] = path
setattr(namespace, self.dest, values)
atexit.register(input, '\nPress Return to finish...')
# Parse command line and update settings
name = os.path.basename(__file__)
description, license1, license2 = __doc__.rpartition('\nCopyright')
license = license1 + license2
parser = argparse.ArgumentParser(prog=name, description=description,
epilog=license, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument('-V', '--version', action='version',
version='{} v{}\n{}'.format(name, version, license))
verbose = parser.add_mutually_exclusive_group()
verbose.add_argument('-v', '--verbose',
dest='verbose', action='store_true',
default=settings.verbose,
help='show detailed info, default %(default)s')
verbose.add_argument('-nv', '--no-verbose',
dest='verbose', action='store_false',
help='don\'t show detailed info')
parser.add_argument('files', nargs='*',
action=CheckPathAction, directory=False,
help='specify a list of files')
parser.add_argument('-d', '--directory',
action=CheckPathAction, directory=True,
help='specify a target directory, if files are not '
'given defaults to the working directory')
recursive = parser.add_mutually_exclusive_group()
recursive.add_argument('-r', '--recursive',
dest='recursive', action='store_true',
default=settings.recursive,
help='include subdirectories, default %(default)s')
recursive.add_argument('-nr', '--no-recursive',
dest='recursive', action='store_false',
help='don\'t include subdirectories')
parser.add_argument('-q', '--jpeg-quality',
metavar='QUALITY', dest='jpeg_q',
type=int, default=settings.jpeg_q,
help='JPEG quality level to be used on compression, '
'default %(default)s')
recompress_jpeg = parser.add_mutually_exclusive_group()
recompress_jpeg.add_argument('-qt', '--recompress-jpeg',
metavar='THRESHOLD',
dest='recompress_jpeg_q_threshold',
const=settings.recompress_jpeg_q_threshold,
default=settings.recompress_jpeg_q_threshold
if settings.recompress_jpeg else None,
nargs='?', type=int,
help='recompress JPEG files, default '
'{}, of at least %(metavar)s quality, default '
'%(const)s'.format(settings.recompress_jpeg))
recompress_jpeg.add_argument('-nqt', '--no-recompress-jpeg',
dest='recompress_jpeg_q_threshold',
action='store_const', const=None,
help='don\'t recompress JPEG files')
convert_to_jpeg = parser.add_mutually_exclusive_group()
convert_to_jpeg.add_argument('-j', '--convert-to-jpeg',
metavar='THRESHOLD',
dest='convert_to_jpeg_ratio_threshold',
const=settings.convert_to_jpeg_ratio_threshold,
default=
settings.convert_to_jpeg_ratio_threshold
if settings.convert_to_jpeg else None,
nargs='?', type=float,
help='convert PNG files to JPEG, default {}, '
'if the size ratio is below %(metavar)s, '
'default %(const)s'.format(
settings.convert_to_jpeg))
convert_to_jpeg.add_argument('-nj', '--no-convert-to-jpeg',
dest='convert_to_jpeg_ratio_threshold',
action='store_const', const=None,
help='don\'t convert PNG files to JPEG')
paths = parser.add_argument_group('optional arguments - paths')
paths.add_argument('-pi', '--identify-path', metavar='FILENAME',
dest='identify',
action=CheckPathAction, executable=True,
default=settings.identify,
help='path to ImageMagick\'s identify executable, '
'default "%(default)s"')
paths.add_argument('-pm', '--mogrify-path', metavar='FILENAME',
dest='mogrify',
action=CheckPathAction, executable=True,
default=settings.mogrify,
help='path to ImageMagick\'s mogrify executable, '
'default "%(default)s"')
paths.add_argument('-pc', '--convert-path', metavar='FILENAME',
dest='convert',
action=CheckPathAction, executable=True,
default=settings.convert,
help='path to ImageMagick\'s convert executable, '
'default "%(default)s"')
paths.add_argument('-pj', '--jpegtran-path', metavar='FILENAME',
dest='jpegtran',
action=CheckPathAction, executable=True,
default=settings.jpegtran,
help='path to mozjpeg\'s jpegtran executable, '
'default "%(default)s"')
paths.add_argument('-po', '--optipng-path', metavar='FILENAME',
dest='optipng',
action=CheckPathAction, executable=True,
default=settings.optipng,
help='path to optipng executable, default "%(default)s"')
# XXX: -qt and -j handling is very ugly
# parse_args(namespace=settings) doesn't override with arguments' defaults
for key, value in vars(parser.parse_args()).items():
setattr(settings, key, value)
settings.recompress_jpeg = settings.recompress_jpeg_q_threshold is not None
settings.convert_to_jpeg = \
settings.convert_to_jpeg_ratio_threshold is not None
if not settings.verbose:
atexit.unregister(input)
# Show settings used
if settings.verbose:
print('{} v{}\n\n'
'Settings\n'
' Files: {s.files}\n'
' Directory: {s.directory}\n'
' Recursive: {s.recursive}\n'
' JPEG quality: {s.jpeg_q}\n'
' Recompress JPEG: {s.recompress_jpeg}\n'
' Recompress JPEG - quality treshold: '
'{s.recompress_jpeg_q_threshold}\n'
' Convert PNG to JPEG: {s.convert_to_jpeg}\n'
' Convert PNG to JPEG - size ratio threshold: '
'{s.convert_to_jpeg_ratio_threshold}\n'
' identify path: {s.identify}\n'
' mogrify path: {s.mogrify}\n'
' convert path: {s.convert}\n'
' jpegtran path: {s.jpegtran}\n'
' optipng path: {s.optipng}\n'
.format(name, version, s=settings))
if pywin32_err and os.name == 'nt':
print('Error importing pywin32: {}\nCan\'t keep creation date\n'
.format(pywin32_err), file=sys.stderr)
# Process files
processed_files = changed_files = input_size = output_size = 0
try:
if settings.files:
for file in settings.files:
_, output_size_file, input_size_file = optimize_image(file)
processed_files += 1
if output_size_file != input_size_file:
changed_files += 1
output_size += output_size_file
input_size += input_size_file
else:
if settings.directory is None:
settings.directory = '.'
if settings.verbose:
print('No files or directory specified, processing the '
'files in the current working directory\n {}\n'
.format(os.getcwd()))
processed_files, changed_files, output_size, input_size = \
optimize_directory(settings.directory)
except:
import traceback
traceback.print_exc()
sys.exit(1)
# Report results
if settings.verbose:
if processed_files:
print('\n{} files processed, {} files changed, '
'{:.2%} file size ratio'.format(processed_files,
changed_files, output_size / input_size))
else:
print('No files processed')
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment