Created
October 24, 2014 15:36
-
-
Save vdcrim/ca909861ec1b77029c37 to your computer and use it in GitHub Desktop.
Optimize image files
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
#!/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 <[email protected]> | |
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