File: shrinkpix/shrinkpix.py
#!/usr/bin/env python3
"""
=============================================================================
shrinkpix.py - shrink images for faster (and politer) online viewing.
Version: 1.3, September 30, 2020 (see VERSIONS below)
Author: © M. Lutz (learning-python.com)
License: provided freely but with no warranties of any kind
Website: https://learning-python.com/shrinkpix/
Bundled: restore-unshrunk-images.py and collect-unshrunk-images.py
Related: thumbspage gallery builder, at learning-python.com/thumbspage.html
-----------------------------------------------------------------------------
CAUTION
-----------------------------------------------------------------------------
This script changes images in place, but saves originals first, and
includes a utility that backs out all changes made. More fundamentally,
this script works well for the website it targets (see RESULTS ahead),
but has not yet been widely used, and remains experimental. Run it on
a temporary copy of your website first, and always inspect the quality
of its results before publishing them. Image-size reduction is a complex
task; this script demos just a few techniques in this domain, and other
tools may do better. On the other hand, this program is free, fun to
code, runs locally on your machine, and may serve as inspiration or base.
-----------------------------------------------------------------------------
INSTALL
-----------------------------------------------------------------------------
Download shrinkpix from here and unzip:
https://learning-python.com/shrinkpix/shrinkpix-full-package.zip
shrinkpix requires Python 3.X to run its code, and the Pillow (a.k.a.
PIL) third-party library for image processing, and is expected to run
on any system that supports both tools, including Mac OS, Windows,
Linux, and Android. Install if needed from here:
https://www.python.org/downloads/
https://pypi.python.org/pypi/Pillow
Shrunken-image quality is the same for Pillow versions 4.2 through 7.0.
shrinkpix also uses the piexif third-party library in a minor role, but
includes its code directly; see the piexif/ folder here for details.
-----------------------------------------------------------------------------
USAGE
-----------------------------------------------------------------------------
This program is configured by uppercase settings at "# Configurations"
in code ahead, and may be run with a command line of this form:
$ python3 shrinkpix.py (<folderpath> | <filepath>)? -listonly? -toplevel?
As usual, add a "> saveoutput.txt" to retain the script's output. In
more detail, shrinkpix can be run with 0 to 3 command-line arguments
in any order, as follows:
- If an argument other than -listonly and -toplevel is given, shrinkpix
expects it to be the pathname of either a folder tree or an image file.
For a folder tree, it walks the entire tree and shrinks all its images.
For an image file, it shrinks just that one specific image alone.
- If -listonly is included in the arguments, shrinkpix shows large images
to be shrunk, but does not update them. This previews changes in both
folder-tree and image-file modes, and works similarly in utility scripts.
- If -toplevel is included in the arguments and a folder-tree name is
provided, shrinkpix shrinks just the images in the top level of the
tree, skipping any subfolders. This option walks a folder instead
of a full tree, and is ignored when shrinking an individual image.
Use the same option to limit walks in utility scripts (see CAVEATS).
- In all cases, arguments omitted default to settings in the code below.
The folder-or-file argument defaults to SHRINKEE, and -listonly and
-toplevel default to LISTONLY and TOPLEVEL, respectively. This may
be useful for focused goals and IDEs lacking command-line support.
-----------------------------------------------------------------------------
PURPOSE
-----------------------------------------------------------------------------
Run this script to reduce the filesize of images you post online.
This program may be used to shrink either a specific image or all images
in a folder tree. In its broadest role, it finds all image files larger
than a given size in a website's tree, and attempts to shrink them down
to the configurable target size using a series of transformations. For
more focused goals, the same reduction may be applied to individual images.
When posted online, the smaller images this program creates can avoid (or
at least minimize) delays for slow servers and/or clients, and are politer
to visitors with limited or metered bandwidth. Per evidence so far (and
the next section), views of images shrunk by this script are plainly faster.
As a bonus, shrinkpix runs locally on your computer from a console, IDE,
or website build script, and neither uses nor requires network connectivity.
This script's original impetus was the full-size image-viewer pages in
galleries created by thumbspage (learning-python.com/thumbspage.html).
If you use thumbspage, after this script is run, be sure to remake your
site's image galleries for the new image filesize and dimensions info
displayed in popups, and upload the results to your host. For more on
this utility's roles, see thumbspage's UserGuide.html#imagesizeandspeed.
-----------------------------------------------------------------------------
RESULTS
-----------------------------------------------------------------------------
Today, the smaller image files created by this program are good enough
to be adopted globally at learning-python.com. It's not uncommon for the
program to reduce a 6M image to 200-300K: a 20-30X decrease in size, with
proportionate decreases in download time and bandwidth usage, and no readily
discernable decrease in visual quality. This also lessens the size of
image-laden download packages massively; the largest fell from 250M to 110M.
Though results will vary per network and image, the speedup for views of
shrunken images seems palpable and noticeable, and justifies the minor and
rare quality hits. This program does come with some caveats listed ahead,
and may improve in time; for now, it's already a win for its target use case.
-----------------------------------------------------------------------------
BACKUPS
-----------------------------------------------------------------------------
This script always saves original (unshrunk) images in subfolders named
"_shrinkpix-originals/" and located in the same folder as the original
image itself, unless backups are disabled in configurations. These
backup subfolders are automatically created when needed. When a new
image is added and shrunk in an already-shrunk folder, its original
is simply added to the existing backup subfolder (if still present).
Because images below the size cutoff are skipped, images are normally
shrink just once. If an image is ever reshrunk (by missing the cutoff,
or being added anew), same-named versions in the backups subfolder
saved by later runs will have a counter "__N" added just before their
original filenames' extensions to make them unique (e.g., "xxxx__2.jpg").
To restore unshrunk originals, move images from all backups subfolders
in a tree to their parent folder, ignoring any files with "__N" names.
The bundled utility "restore-unshrunk-images.py" does this automatically,
and fully restores the folder tree to its preshrink state in the process.
For convenience, the bundled "collect-unshrunk-images.py" instead moves
all of a tree's backup folders to its root "_shrinkpix-all-originals/"
(e.g., to retain but exclude them from site uploads), and moves later
additions to existing backup folders there. You can also collect backup
folders to an alternate folder outside the source tree, and can later
restore originals from a collection tree by a restore + rsync combination.
For usage details on restores and collections, see the utility scripts.
In typical usage, you might use this system's scripts to just shrink;
shrink and restore; shrink and collect; or shrink, collect, and restore.
Note that backup folder names can be changed in settings, but must agree
between script runs for restores and collections to function properly.
-----------------------------------------------------------------------------
MECHANICS
-----------------------------------------------------------------------------
This script primarily supports and shrinks JPEG, PNG, GIF, and BMP
images, because these are broadly supported by web browsers, but some
other image types work in its code too (see issupported() ahead). Its
shrinking algorithm may seem arbitrary, but yields acceptable results:
0) Images already below the target size are skipped and unchanged.
1) For all other images, transformations are tried in turn until the image
file is smaller than the target size, or no more transformations remain.
2) For JPEGs, the script tries to "optimize" the image and then decrease
its "quality" per the Pillow options of these names; then resizes the
original image by lowering its dimensions repeatedly using a progression
of increasing scale factors, using "optimize" and "quality" for each.
3) For PNGs, the script attempts Pillow's "optimize" setting; then its
"quantize()" method; then resizes the original but quantized image as
in #2, applying "optimize" on each result tried.
4) For GIFs and any others, the script attempts Pillow's "optimize"
setting; then resizes the original image as in #2, with "optimize".
JPEGs also retain their original Exif tags when resaved to files, with
dimension tags updated to reflect the new size of images shrunk by the
last-resort resizeTillSmall(). This update is not crucial (this script
by design produces images of reduced quality that are meant only for online
display), but it may make downscaled images work better in other tools.
PNGs currently do propagate Exif tags, though Exif has recently been
standardized for PNGs; some PNGs record Exif data in ad-hoc ways; and
Pillow's PngImageFile.getexif() may provide options on this front (TBD).
Unsupported image types over the size cutoff are reported at the end of
tree-walk runs, but unchanged. In single-image runs, some additional
image types may work in the code as is; this was largely soft-pedaled,
because most browsers don't support exotic types even if Pillow does.
The tree walker always skips Unix-hidden and developer-private folders,
whose names start with a "." and "_", respectively; any other folder
names you configure to skip in code ahead; and any backup folders found
along the way (else this would shrink saved originals). If the walker's
scope is still too broad, run it on individual subdirs in your tree.
References for the Pillow library's tools employed by this program,
all of which reside at https://pillow.readthedocs.io/en/stable:
Resizing: /reference/Image.html#PIL.Image.Image.resize
Save options: /handbook/image-file-formats.html
Quantize: /reference/Image.html#PIL.Image.Image.quantize
Color modes: /handbook/concepts.html#concept-modes
Resize filters: /handbook/concepts.html#filters
-----------------------------------------------------------------------------
CAVEATS
-----------------------------------------------------------------------------
Though this script's results are good enough to adopt at its target
site (see RESULTS above), it comes with some tradeoffs you should be
aware of up front. In addition to the caution at the top of this file:
Speed
This script can run a _long_ time for trees with many large images.
Per the examples/ folder, an older 2015 MacBook Pro took 3 minutes
to shrink 71 images, and about 8 minutes to shrink 125. This likely
makes shrinkpix impractical to run in some websites' build scripts.
The upside is that it runs locally; its slowest run is usually an
initial one-time event for existing sites; and using it on individual
images later is quick. Shrunken images themselves generally load much
faster in all contexts, though the improvement depends on many factors.
UPDATE: large images naturally take longer to shrink. In newer
testing with the latest Python, Pillow, and 2020 devices, shrinking
a 108MP JPEG image in a 20M file required a full 13 seconds on a
2019 MacBook Pro with an 8-Core Intel Core i9 and 16M. Even then,
the image had to be reshrunk (at 2-3 seconds) to hit size < 512K.
There seems ample room for optimization, both in Pillow and here.
Failures
Though rare, this script may fail to shrink some images to the target
size; search for "*SAVED ABOVE MAXSIZE*" in run output to see failures.
Convert these manually from originals to avoid re-shrinkpixing them
if desired (reshrinking may leave duplicates in backup folders).
UPDATE: especially for large JPEGs, it may suffice to simply _rerun_
shrinkpix after images are saved above the target size; the next run's
resizing will likely complete the shrinkage, and it may be difficult
to tell the difference in quality except when blown up to actual size.
PNG mileage may vary, though disabling quantize() ahead may help.
Individual images may also fail to shrink at all, most commonly due
to miscoded Exif tags that trigger failures in the piexif library;
look for "***Unable to shrink: image skipped" in the output for details.
These don't cause a run to terminate, but the failing image is unshrunk.
UPDATE: a few miscoded tags are now fixed to minimize piexif failures:
see "[1.3]" changes here. Unfixed tags may still fail and cause images
to be skipped as described; edit the miscoded tag with another tool.
Quality
In use so far, both JPEGs and PNGs normally shrink with little or no
visual degradation, but a few PNGs may appear subpar. Though atypical,
shadows may render as discernable bands instead of being continuous;
portions of images may lose colors occasionally; and subtle details
like light text may render blurry. This appears to be the work of
the default Pillow quantize(), though resizing is worse. Always be
sure to inspect results before posting, restore and manually shrink
those you don't like, and watch for possible shrinkpix updates. See
examples/_subpar-pngs for examples and more details on PNG quality loss.
UPDATE: in later practice, JPEGs have done well with this script,
but some PNGs have suffered visible quality loss. The shrinkpix
team is happy to take suggestions for improvements by email, at
lutz@learning-python.com. As is, this script has more potential
than developer attention, though its code is good enough for many
use cases, and can be used as a framework for exploring ideas.
Thumbnails
The shrunken images produced by this script might yield thumbnails
of lesser quality than the originals. The thumbspage system solved
this by converting some images to "RGBA" color mode temporarily when
making their thumbnails; this produces results as good as for unshrunk
originals. You may also avoid quality loss by making thumbnails from
originals before downscaling, or from auto-saved originals afterwards.
See thumbspage's UserGuide.html#thumbnails17 for more background.
Merges
As described at BACKUPS above, before shrinking an image in place,
this program saves the original in a "_shrinkpix-originals" backups
subfolder located in the same folder as the original. This avoids
files with different extensions (e.g., ".backup"), but can lead to
collisions if multiple folders with backups are merged into a union.
If this applies to your use case, rename backup folders manually to
make them unique before merging, or use the collector script to move
them out of the source trees to a folder that's unique or unmerged.
Utilities
If you use -toplevel for a folder here, you probably want to use it
for the folder in the restore and collector scripts too, to prevent
these scripts from processing backups in separately managed subfolders;
see these scripts' -toplevel docs for more background. Also see the
collector script for a caveat regarding tree-structure changes; in
short, you may not be able to restore from a separate collection tree
if its backup paths have been invalidated by source-tree changes.
UPDATE: you may also need to change the DROPDUPS preset in the
restore script to avoid restoring unwanted duplicates. See that
script for more details; its preset in version [1.3] differs from
prior releases for good (but subtle) reasons, though you may
need to manually remove "__N" duplicates after a restore.
Settings
The run options of this script have evolved with use, but some may be
easier to vary if moved to a separate file or command-line argument
(though complicated command lines are explicitly discouraged here).
Design
A "backup-to" option here and a "backup-from" in the restore script
could make the collector script and its rsync restores unnecessary.
Neither was implemented because they seem to muddy the waters with too
much complexity; restores from collections are error prone if the source
tree has changed; and this is not (yet?) justified by use cases or users.
This also wouldn't make sense when shrinking individual files directly
here: what would the root-relative path be in the "backup-to" folder?
In the end, pulled collections may be best used for archiving originals.
File counts
The total files count displayed at the end of a run be one higher
than you expect on Mac OS, due to a ".DS_Store" file now fully hidden
by Mac OS's Finder. Alas, shrinkpix can't fix OSs that cheat.
-----------------------------------------------------------------------------
VERSIONS
-----------------------------------------------------------------------------
- 1.3, Sep-30-2020:
1) Silence a bogus DOS warning issued by the Pillow library for
large (>89MP) images, and avoid a Pillow exception for images
twice this size. 108MP is now common on high-end smartphones.
2) Also repair some miscoded Exif tags to avoid piexif failures
which were formerly caught but caused miscoded-image skips.
Both were adopted from thumbspage, whose user guide has details:
[DOS] htps://learning-python.com/thumbspage/UserGuide.html#_20E
[Exif] htps://learning-python.com/thumbspage/UserGuide.html#_17G
The restore script also now ships with DROPDUPS=False, and the
collector script can now be run with no arguments for usage info.
All 3 scripts no longer print a traceback on ctrl+c at run confirm.
- 1.2, Jun-12-2020:
Catch piexif lib's bad-tag exception, and skip subject image.
piexif raises excs for unexpected types for rare tags from some
cameras. This change also catches and recovers from other excs.
More details: examples/example5-1.2-piexif-exc-skips-demo.txt.
- 1.1, Mar-08-2020:
Add -toplevel option to command lines in all three scripts.
Use piexif to update some Exif dimension tags propagated to JPEGs
whose dimensions have been resized. New screenshots + examples.
- 1.0, Mar-02-2020:
Initial release, separate from initial dev home in thumbspage.
=============================================================================
"""
import os, sys, shutil, math, mimetypes, io
from PIL import Image
import piexif
#----------------------------------------------------------------------------
# [1.3] Sep-2020: silence a harmless but excessive Pillow-library warning
# now issued stupidly for all large images. This includes perfectly valid
# 108MP images shot on a Note20 Ultra smartphone, among other >89M image
# devices. This also impacted thumbspage, tagpix, and PyPhoto, requiring
# program rereleases - a typical open-source-agenda result, and an example
# of the pitfalls of "batteries included" development. Fix, please.
# More details: thumbspage or tagpix UserGuide.html#pillowdoswarning.
# Update: Pillow makes this an error _exception_ at limit*2: disable too.
#----------------------------------------------------------------------------
Image.MAX_IMAGE_PIXELS = None # stop both warning, and error at limit*2
# in case the preceding fails
if hasattr(Image, 'DecompressionBombWarning'): # not until 2014+ Pillows
import warnings
warnings.simplefilter('ignore', Image.DecompressionBombWarning)
#============================================================================
# Configuration
#============================================================================
# Main settings (command-line arguments override some: see above)
TRACE = True # True=print transformation used too
LISTONLY = False # True=list but do not change large images
MAXSIZE = 500 * 1024 # if size > this, shrink to this or less (500k)
SHRINKEE = '/YOUR-STUFF/Websites/UNION' # path to folder tree or image to shrink
# Advanced settings - don't change unless you're sure of the impact
JPEGQUALITY = 85 # JPEG quality downscale value (95 max)
TRYRESIZES = [.80, .60, .40] # All-types resize scale-down %s (1.0 max)
PNGQUANTS = dict() # extra args for quantize() (none for now)
BACKUPSDIR = '_shrinkpix-originals' # where unshrunk originals are autosaved
ALLBACKUPSDIR = '_shrinkpix-all-originals' # where collector script moves backup dirs
NOBACKUPS = False # True=don't save originals (iff trusted!)
SUBDIRSKIPS = ('_thumbspage', 'thumbs') # walker: subfolders to skip (thumbnails,..)
SUBDIRFORCE = () # walker: override all skips to shrink these
TOPLEVEL = False # walker: skip all tree subs - folder walk
#============================================================================
# Utilities
#============================================================================
def trace(message):
if TRACE: print(' '*3, '[%s]' % message)
def isimage(filename):
mimetype = mimetypes.guess_type(filename)[0] # (type?, encoding?)
return mimetype != None and mimetype.split('/')[0] == 'image' # e.g., 'image/jpeg'
# Conveniences
mimeType = lambda filename: mimetypes.guess_type(filename)[0]
imageType = lambda filename: mimetypes.guess_type(filename)[0].split('/')[1]
def issupported(filename):
"""
-------------------------------------------------------------------------
Define the image types shrunk in tree-walker mode. Change it to be more
or less inclusive (e.g., skip just icons?). Image types that don't pass
the test here can be converted in single-image mode. Example: TIFFs work
as singles, but may degrade in quality too much to support in tree walks.
-------------------------------------------------------------------------
"""
return imageType(filename) in ['jpeg', 'png', 'gif', 'bmp'] # not extension
#============================================================================
# Mechanics
#============================================================================
def backupOriginal(folder, file, path):
"""
-------------------------------------------------------------------------
Save original, unshrunk image to a subfolder before changing it.
The saves subfolder is in the same folder as the original image.
Use "__N" filenames to avoid overwriting backups from prior runs.
NOBACKUPS saves cleanup time, if you _really_ trust this script.
-------------------------------------------------------------------------
"""
if NOBACKUPS: return
savedir = os.path.join(folder, BACKUPSDIR)
if not os.path.exists(savedir):
os.mkdir(savedir)
savepath = os.path.join(savedir, file)
if os.path.exists(savepath):
# don't clobber prior-run copies
savehead, saveext = os.path.splitext(savepath) # xxxxx, .yyy
copynum = 2
while True:
savepath = savehead + '__' + str(copynum) + saveext
if not os.path.exists(savepath):
break
copynum += 1
shutil.copy2(path, savepath) # backup: data + metadata
def getImageFormat(imgname):
"""
-------------------------------------------------------------------------
Get an image format from filename for buffer saves, where types vary
and the filename cannot be used for the save. This also works in older
Pillows, where setting image.name won't suffice. Copied from PyPhoto:
learning-python.com/pygadgets-products/unzipped/_PyPhoto/PIL/viewer_thumbs.py.
Note that Pillow's types may or may not match Python's mimetypes maps.
-------------------------------------------------------------------------
"""
try:
from PIL.Image import registered_extensions # where available
EXTENSION = registered_extensions() # ensure plugins init() run
except:
from PIL.Image import EXTENSION # else assume init() was run
ext = os.path.splitext(imgname)[1].lower() # lookup ext in Pillow table
format = EXTENSION[ext] # fairly brittle, this...
return format
def saveToFile(imgbytes, path):
"""
-------------------------------------------------------------------------
Save image's bytes to a file: no need for another Pillow save() here.
-------------------------------------------------------------------------
"""
savefile = open(path, 'wb')
savefile.write(imgbytes)
savefile.close()
def saveToBuffer(image, path, **saveoptions):
"""
-------------------------------------------------------------------------
Get content of image without an actual file, by saving to bytes buffer.
saveoptions vary between JPEG and others (JPEG uses quality and exif).
Others would silently ignore JPEG's quality, but might not ignore exif.
-------------------------------------------------------------------------
"""
filename = os.path.basename(path)
imgformat = getImageFormat(filename) # for Pillows that require it
image.name = filename # for Pillows that recognize it
buffer = io.BytesIO()
image.save(buffer, imgformat, **saveoptions)
filebytes = buffer.getvalue()
return filebytes
def fixFailingExifTags(parsedexif):
"""
-------------------------------------------------------------------------
[1.3] Sep-2020: piexif bug temp workaround: correct uncommon Exif tags
(e.g., 41729, which is piexif.ExifIFD.SceneType) whose miscoding on some
devices triggers an exception in piexif's dump() - but not its load().
Other failing tags will still fail in dump(), and skip shrinkage in full.
Until piexif addresses this more broadly, this munges SceneType from
int to byte, and converts another from tuple to bytes*4 if needed (and
defensively: stuff happens). The piexif exceptions formerly caused
miscoded images to be skipped by shrinkpix processing. Bug reports:
tag 41729 => https://github.com/hMatoba/Piexif/issues/95
tag 37121 => https://github.com/hMatoba/Piexif/issues/83
This code was adapted from thumbspage (which borrowed from here too).
-------------------------------------------------------------------------
"""
parsedExif = parsedexif['Exif'] # parsed tags: dict of dicts
# fix SceneType? 1 => b'\x01'
if 41729 in parsedExif:
tagval = parsedExif[41729] # miscoded on some Galaxy
if type(tagval) is int: # munge from int to byte
if 0 <= tagval <= 255:
parsedExif[41729] = bytes([tagval])
trace('--Note: bad SceneType Exif tag type was corrected--')
else:
del parsedExif[41729]
trace('--Note: bad SceneType Exif tag type was dropped--')
# fix ComponentsConfiguration? (1, 2, 3, 0) => b'\x01\x02\x03\x00'
if 37121 in parsedExif:
tagval = parsedExif[37121]
if type(tagval) is tuple:
if (len(tagval) == 4 and
all(type(x) is int for x in tagval) and
all(0 <= x <= 255 for x in tagval)):
parsedExif[37121] = bytes(tagval)
trace('--Note: bad ComponentsConfiguration Exif tag was corrected--')
else:
del parsedExif[37121]
trace('--Note: bad ComponentsConfiguration Exif tag was dropped--')
# other tag failures cause shrinking be skipped for an image
def fixJpegExifSize(image, saveoptions):
"""
-------------------------------------------------------------------------
Change the dimension tags in a JPEG's Exif data to reflect the image's
new, reduced size. This uses the piexif third-party lib because Pillow
has almost no Exif support, apart from fetching and saving raw Exif bytes;
piexif parses and composes the data, and is easy-to-ship pure-Python code.
Largely copied from thumbspage, where changing Orientation is more crucial.
Could run the parse just once, but image processing is much more costly.
[1.2] piexif.dump() can fail for some oddball tags; skip excs in caller;
we don't try to fix tags in shrinkpix - for a program which does, plus
additional background detail on the piexif design flaw, see thumbspage,
at: learning-python.com/thumbspage/UserGuide.html#piexifworkaround
[1.3] this now _does_ try to fix a few tags, with code borrowed from
thumbspage: see the new fixFailingExifTags(); other tags may still fail.
-------------------------------------------------------------------------
"""
if "exif" in saveoptions: # set for JPEGs only
origexifs1 = saveoptions['exif'] # Exif bytes from Pillow
if origexifs1: # piexif bombs if b''
parseexifs = piexif.load(origexifs1) # parse into a dict
# [1.3] piexif work-around: fix tags known to trigger exceptions
fixFailingExifTags(parseexifs)
# update dimension tags
parseexifs["Exif"][piexif.ExifIFD.PixelXDimension] = image.width
parseexifs["Exif"][piexif.ExifIFD.PixelYDimension] = image.height
origexifs2 = piexif.dump(parseexifs) # back to a bytes
saveoptions['exif'] = origexifs2 # for Pillow save
return saveoptions
def resizeTillSmall(image, path, **saveoptions):
"""
-------------------------------------------------------------------------
Apply resize factors till small enough, always restarting with original.
The resizing here shrinks image, but preserves the original aspect ratio.
resize() returns a new copy (unlike thumbnail()); assume image unchanged.
This is a last-ditch attempt to shrink, iff image-specific options fail.
For PNGs, image is not the original: it has already been quantized().
-------------------------------------------------------------------------
"""
assert len(TRYRESIZES) > 0
oldwide, oldhigh = image.width, image.height # or image.size
for resizepct in TRYRESIZES:
newwide, newhigh = oldwide * resizepct, oldhigh * resizepct
newwide, newhigh = math.floor(newwide), math.floor(newhigh)
resize = image.resize((newwide, newhigh), resample=Image.LANCZOS)
saveoptions = fixJpegExifSize(resize, saveoptions)
newfilebytes = saveToBuffer(resize, path, **saveoptions)
if len(newfilebytes) <= MAXSIZE:
break
# last resize is small enough, or as small as can be
trace('resized at %0.2f' % resizepct)
saveToFile(newfilebytes, path)
# did we make the cutoff?
if len(newfilebytes) > MAXSIZE:
trace('*SAVED ABOVE MAXSIZE*')
def shrinkJPEG(image, path):
"""
-------------------------------------------------------------------------
Change quality and optimize, then resize; propagate Exif tags if present.
JPEGs generally shrink very well, with little or no resizing required.
Converting PNGs to JPEGs doesn't work well for things like screenshots.
TBD: could use quality here first, but size/visual diffs negligible.
Exif tags are propagated; their dimensions are also updated if needed.
-------------------------------------------------------------------------
"""
oldexifs = image.info.get('exif', b'') # raw bytes, if any, via Pillow
saveoptions = dict(optimize=True, exif=oldexifs)
newfilebytes = saveToBuffer(image, path, **saveoptions)
if len(newfilebytes) <= MAXSIZE:
trace('optimize')
saveToFile(newfilebytes, path)
else:
saveoptions.update(quality=JPEGQUALITY)
newfilebytes = saveToBuffer(image, path, **saveoptions)
if len(newfilebytes) <= MAXSIZE:
trace('optimize+quality')
saveToFile(newfilebytes, path)
else:
trace('optimize+quality+resize')
resizeTillSmall(image, path, **saveoptions)
def shrinkPNG(image, path):
"""
-------------------------------------------------------------------------
Optimize (no quality), then quantize+optimize ("P" format, 256 colors),
then resize the quantized image. quantize()'s "method" option didn't
help: 0-1 aren't for RGB, 3 requires a plugin, and 2's diff was trivial.
There are more advanced quantize() options, but they're beyond scope here.
Avoid resizing if at all possible: it can blur text/detail badly for PNGs.
Some PNGs may have Exifs by kludge or a newer standard: ignore them here.
-------------------------------------------------------------------------
"""
newfilebytes = saveToBuffer(image, path, optimize=True)
if len(newfilebytes) <= MAXSIZE:
trace('optimize')
saveToFile(newfilebytes, path)
else:
quantimage = image.quantize(**PNGQUANTS)
newfilebytes = saveToBuffer(quantimage, path, optimize=True)
if len(newfilebytes) <= MAXSIZE:
trace('optimize+quantize')
saveToFile(newfilebytes, path)
else:
trace('optimize+quantize+resize')
resizeTillSmall(quantimage, path, optimize=True)
def shrinkOther(image, path):
"""
-------------------------------------------------------------------------
Optimize (no quality), then resize. Used for GIFs, and issupported() or
directly shrunk others (e.g., TIFFs work here, but browsers may not show).
TBD: could specialize here: TIFFs have quality arg, quantize() others?
-------------------------------------------------------------------------
"""
newfilebytes = saveToBuffer(image, path, optimize=True)
if len(newfilebytes) <= MAXSIZE:
trace('optimize')
saveToFile(newfilebytes, path)
else:
trace('optimize+resize')
resizeTillSmall(image, path, optimize=True)
def resizeOne(path, folder, file, indent=' '*4):
"""
-------------------------------------------------------------------------
Resize a single image; used by both tree-walker and single-image modes,
though the walker's usage may depend on the coding of issupported().
[1.2] catch all exceptions here - including piexif's JPEG tag failures.
-------------------------------------------------------------------------
"""
print(indent + 'Old size: %d bytes' % os.path.getsize(path))
try:
# save a backup copy first
backupOriginal(folder, file, path)
# downscale and update in place
image = Image.open(path)
if mimeType(file) == 'image/jpeg':
shrinkJPEG(image, path)
elif mimeType(file) == 'image/png':
shrinkPNG(image, path)
elif mimeType(file) == 'image/gif':
shrinkOther(image, path)
else:
assert isimage(file)
shrinkOther(image, path)
image.close() # just in case
print(indent + 'New size: %d bytes' % os.path.getsize(path))
except:
# any error: report, continue
print(indent + '***Unable to shrink: image skipped')
print(indent + ('Exception: %s' % sys.exc_info()[1]).replace('\n', '\n'+indent))
#============================================================================
# Tree-walker mode
#============================================================================
def treeWalkerMode(treeroot):
"""
-------------------------------------------------------------------------
Walk tree, resize all its images; tree path in configs or command line.
The focus here is on JPEG, PNG, GIF, and BMP because that's what browsers
support, but other types work too if you modify issupported() above.
-------------------------------------------------------------------------
"""
treeroot = os.path.abspath(treeroot)
missedimgs = []
numfile = numimage = numimagelarge = 0
for (folder, subs, files) in os.walk(treeroot, topdown=True):
# prune the walk tree below here for subtrees to skip
prunes = []
for sub in subs:
if sub in SUBDIRFORCE: # always visit these despite skip tests
continue
if (sub == BACKUPSDIR or # skip prior-run backup dirs: originals
sub == ALLBACKUPSDIR or # skip collected backup dirs: originals
sub in SUBDIRSKIPS or # skip config subdirs: thumbnails, etc.
sub[0] in ['.', '_'] or # skip Unix hidden and developer private
TOPLEVEL): # skip all subs: just top-level images
prunes.append(sub)
for prune in prunes:
subs.remove(prune) # skip this later - and all subs below it
# shrink this folder's images
for file in files:
numfile += 1
if isimage(file):
path = os.path.join(folder, file)
size = os.path.getsize(path)
if size > MAXSIZE and not issupported(file):
# report large images missed
missedimgs.append((path, size))
elif issupported(file):
# downscale these types if large
numimage += 1
if size > MAXSIZE:
numimagelarge += 1
if LISTONLY:
print(path, '[%d bytes, not changed]' % size)
else:
print(path)
resizeOne(path, folder, file) # resize this one
# walker wrap-up
if missedimgs:
print('\nMissed %d large images:' % len(missedimgs))
for missed in missedimgs: print('...', missed)
print()
print('Done: %d files, %d images, %d large images' %
(numfile, numimage, numimagelarge))
#============================================================================
# Single-image mode
#============================================================================
def singleImageMode(filename):
"""
-------------------------------------------------------------------------
Check and resize just one specific image, path/name in command line.
issupported() is not tested here on purpose: TIFFs, etc., work too.
-------------------------------------------------------------------------
"""
path = os.path.abspath(filename)
size = os.path.getsize(path)
if not isimage(path):
print('Not an image file.')
elif size <= MAXSIZE:
print('Already below size cutoff.')
elif LISTONLY:
print('Current size: %d bytes.' % size)
else:
folder, file = os.path.split(path)
resizeOne(path, folder, file)
print('Done.')
#============================================================================
# Top level
#============================================================================
def askyesno(prompt, opts=' (y|n) '):
try:
return input(prompt + opts)
except KeyboardInterrupt: # [1.3] ctrl+c: don't print trackback
print()
return 'no'
if __name__ == '__main__':
if '-listonly' in sys.argv: # for folder or file
LISTONLY = True # else use setting's value
sys.argv.remove('-listonly')
if '-toplevel' in sys.argv: # ignored if shrinkee is a file
TOPLEVEL = True # else use setting's value
sys.argv.remove('-toplevel')
if len(sys.argv) > 1:
shrinkee = sys.argv.pop(1) # any position, last remaining
else:
shrinkee = SHRINKEE # from arg, or else setting
command = 'shrinkpix.py (<folderpath> | <filepath>)? -listonly? -toplevel?'
confirm = 'This script shrinks images in place, after saving originals; continue?'
if len(sys.argv) > 1:
print('Usage:', command) # any more left?: bad args
elif (not LISTONLY) and askyesno(confirm).lower() not in ['y', 'yes']:
print('Run cancelled.')
elif os.path.isdir(shrinkee): # arg|setting=dir: walk this tree or dir
treeWalkerMode(shrinkee)
elif os.path.isfile(shrinkee): # arg|setting=file: resize this file only
singleImageMode(shrinkee)
else:
print('Usage:', command) # what in the world are you shrinking?