wesnoth/utils/woptipng.py
Matthias Krüger b3fe980d70 add woptipng png compression script
An improved version of the wesnoth-optipng shell script.

Supports multiple jobs at a time. ( -j )
Accepts a threshold for discarding images which were only reduced by a few bytes. ( -t 15 will only save images that were reduced in size by at least 15 %).
Niceness can be set using the -n switch.

Usage:
cd <directory>
woptipng . ../some/dir  ../some/some_file.png

The script should achive better compression ratios than the previous script.
The previous script was running optimizations in an unconditional loop and, at the end, did not save the image if pixel colors changed.
This implies if *one* of the crushers did a bad job and changed the images content in a bad way, *none* of the possibly good optimizations by the other crushed would be saved.
Woptipng checks the image every time after a crusher touched it and only reverts a single optimization if it was bad leaving possibility for the other tools to still perform a good job and reduce size of the image.
2016-12-22 15:27:43 +01:00

223 lines
7.9 KiB
Python
Executable file

#!/usr/bin/env python3
# woptipng - attempts to reduce PNGs in size using several other tools
# Copyright (C) 2016 Matthias Krüger
# 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 2, 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, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston MA 02110-1301 USA
# Please file bugs to https://github.com/matthiaskrgr/woptipng
from multiprocessing import Pool
import multiprocessing # cpu count
from PIL import Image as PIL # compare images
import subprocess # launch advdef, optipng, imagemagick
import os # os rename, niceness
import shutil # copy files
import argparse # argument parsing
import sys # sys.exit
parser = argparse.ArgumentParser()
parser.add_argument("inpath", help="file or path (recursively) to be searched for crushable pngss", metavar='path', nargs='+', type=str)
parser.add_argument("-d", "--debug", help="print debug information", action='store_true')
parser.add_argument("-t", "--threshold", help="size reduction below this percentage will be discarded, default: 10%", metavar='n', nargs='?', default=10, type=float)
parser.add_argument("-j", "--jobs", help="max amount of jobs/threads. If unspecified, take number of cores found", metavar='n', nargs='?', default=multiprocessing.cpu_count(), type=int)
parser.add_argument("-n", "--nice", help="niceness of all threads (must be positive)", metavar='n', nargs="?", default=19, type=int)
args = parser.parse_args()
DEBUG = args.debug
INPATHS = args.inpath
THRESHOLD = args.threshold
MAX_THREADS = args.jobs
# program executables
EXEC_OPTIPNG = shutil.which("optipng")
EXEC_IMAGEMAGICK = shutil.which("convert")
EXEC_ADVDEF = shutil.which("advdef")
os.nice(args.nice) # set niceness
input_files=[]
bad_input_files=[]
print("Collecting files... ", end="")
for path in INPATHS: # iterate over arguments
if (os.path.isfile(path)): # inpath is a file
if (path.endswith("png")):
input_files.append(path)
else: # not png?
bad_input_files.append(path)
elif (os.path.isdir(path)): # inpath is a directory
for root, directories, filenames in os.walk(path):
for filename in filenames:
if (filename.split('.')[-1] == "png"): # check for valid filetypes
input_files.append(os.path.join(root,filename)) # add to list
else: # path does not exist
bad_input_files.append(path)
bad_input_files.sort()
input_files.sort()
# get sizes
file_list = []
for file_ in input_files:
file_list.append([file_, os.path.getsize(file_), None])
print(" done")
if (bad_input_files):
print("WARNING: can't handle following files:' ")
print(', '.join(bad_input_files) + "\n")
print("Compressing " + str(len(file_list)) + " pngs...")
def debugprint(arg):
if (DEBUG):
print(arg)
def images_identical(image1, image2):
return PIL.open(image1).tobytes() == PIL.open(image2).tobytes()
def verify_images(source_img, new_img, transform):
no_change = images_identical(source_img, new_img) # image pixels values remain unaltered, we want this
image_got_smaller = os.path.getsize(source_img) > os.path.getsize(new_img)
debugprint("size reduction: " + str(os.path.getsize(source_img) - os.path.getsize(new_img)))
if (no_change and image_got_smaller):
os.rename(new_img, source_img) # move new image to old image // os.rename(src, dest)
else: # we can't os.rename(image_after, image_before) because that would leave us with no source for the next transform
shutil.copy(source_img, new_img) # override new image with old image // shutil.copy(src, dest)
debugprint(("TRANSFORMATION unsuccessfull: + " + transform + ", REVERTING " + source_img))
def run_imagemagick(image, tmpimage):
shutil.copy(image, tmpimage)
debugprint("imagemagick ")
cmd = [ EXEC_IMAGEMAGICK,
"-strip",
"-define",
"png:color-type=6",
image,
tmpimage
]
subprocess.call(cmd)
def run_optipng(image, tmpimage):
debugprint("optipng...")
shutil.copy(image, tmpimage)
cmd = [ EXEC_OPTIPNG,
"-q",
"-o5",
"-nb",
"-nc",
"-np",
tmpimage
]
subprocess.call(cmd)
def run_advdef(image, tmpimage):
debugprint("advdef")
shutil.copy(image, tmpimage)
compression_levels = [1, 2, 3, 4]
for level in compression_levels:
cmd = [
EXEC_ADVDEF,
"-z",
"-" + str(level),
tmpimage,
]
subprocess.call(cmd, stdout=open(os.devnull, 'w')) # discard stdout
def check_progs():
if (not EXEC_ADVDEF):
print("ERROR: advdef binary not found!")
if (not EXEC_IMAGEMAGICK):
print("ERROR: imagemagick/convert binary not found!")
if (not EXEC_OPTIPNG):
print("ERROR: optipng not found!")
if not (EXEC_ADVDEF and EXEC_IMAGEMAGICK and EXEC_OPTIPNG):
sys.exit(1)
def optimize_image(image):
size_initial = os.path.getsize(image)
with open(image, 'rb') as f:
initial_file_content = f.read()
size_initial = os.path.getsize(image)
it=0
size_after = 0
size_before = os.path.getsize(image)
while ((size_before > size_after) or (not it)):
it+=1
debugprint(("iteration " + str(it)))
size_before = os.path.getsize(image)
tmpimage = image + ".tmp"
run_imagemagick(image, tmpimage)
verify_images(image, tmpimage, "imagemagick")
run_optipng(image, tmpimage)
verify_images(image, tmpimage, "optipng")
run_advdef(image, tmpimage)
verify_images(image, tmpimage, "advdef")
size_after = os.path.getsize(image)
size_delta = size_after - size_initial
perc_delta = (size_delta/size_initial) *100
if (DEBUG and (size_initial < size_after)):
debugprint("WARNING: " + str(image) + "got bigger !")
if os.path.isfile(tmpimage): # clean up
os.remove(tmpimage)
if (os.path.getsize(image) > size_initial) or (perc_delta*-1 < THRESHOLD) : # got bigger, or exceeds threshold
with open(image, 'wb') as f: # write back original file
f.write(initial_file_content)
else:
print("optimized {image} from {size_initial} to {size_after}, {size_delta}b, {perc_delta}%".format(image=image, size_initial=size_initial, size_after=size_after, size_delta=size_delta, perc_delta=str(perc_delta)[0:6]))
check_progs() # all tools available? if not: exit
# do the crushing
p = Pool(MAX_THREADS)
p.map(optimize_image, set(input_files))
# update file_list
for index, file_ in enumerate(file_list):
file_list[index][2] = os.path.getsize(file_[0]) # write new filesize into list
# obtain stats
size_before = 0
size_after = 0
files_optimized = 0
for i in file_list:
if i[1] > i[2]: # file got smaller
size_before += i[1]
size_after += i[2]
files_optimized += 1
# print stats
if (files_optimized):
print("{files_optimized} of {files_processed} files optimized, {size_before} bytes reduced to {size_after} bytes; {size_diff} bytes, {percentage_delta}%".format(files_optimized = files_optimized, files_processed = len(file_list), size_before = size_before, size_after=size_after, size_diff = size_after - size_before, percentage_delta = str((size_after - size_before)/(size_before)*100)[0:6]))
print("Optimization threshold was " + str(THRESHOLD) + "%")
else:
print("Nothing optimized")