compressimages.py 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183
  1. from PIL import Image, ImageFile
  2. from sys import exit, stderr
  3. from os.path import getsize, isfile, isdir, join
  4. from os import remove, rename, walk, stat
  5. from stat import S_IWRITE
  6. from shutil import move
  7. from argparse import ArgumentParser
  8. from abc import ABCMeta, abstractmethod
  9. class ProcessBase:
  10. """Abstract base class for file processors."""
  11. __metaclass__ = ABCMeta
  12. def __init__(self):
  13. self.extensions = []
  14. self.backupextension = 'compressimages-backup'
  15. @abstractmethod
  16. def processfile(self, filename):
  17. """Abstract method which carries out the process on the specified file.
  18. Returns True if successful, False otherwise."""
  19. pass
  20. def processdir(self, path):
  21. """Recursively processes files in the specified directory matching
  22. the self.extensions list (case-insensitively)."""
  23. filecount = 0 # Number of files successfully updated
  24. for root, dirs, files in walk(path):
  25. for file in files:
  26. # Check file extensions against allowed list
  27. lowercasefile = file.lower()
  28. matches = False
  29. for ext in self.extensions:
  30. if lowercasefile.endswith('.' + ext):
  31. matches = True
  32. break
  33. if matches:
  34. # File has eligible extension, so process
  35. fullpath = join(root, file)
  36. if self.processfile(fullpath):
  37. filecount = filecount + 1
  38. return filecount
  39. class CompressImage(ProcessBase):
  40. """Processor which attempts to reduce image file size."""
  41. def __init__(self):
  42. ProcessBase.__init__(self)
  43. self.extensions = ['jpg', 'jpeg', 'png']
  44. def processfile(self, filename):
  45. """Renames the specified image to a backup path,
  46. and writes out the image again with optimal settings."""
  47. try:
  48. # Skip read-only files
  49. if (not stat(filename)[0] & S_IWRITE):
  50. print 'Ignoring read-only file "' + filename + '".'
  51. return False
  52. # Create a backup
  53. backupname = filename + '.' + self.backupextension
  54. if isfile(backupname):
  55. print 'Ignoring file "' + filename + '" for which existing backup file is present.'
  56. return False
  57. rename(filename, backupname)
  58. except Exception as e:
  59. stderr.write('Skipping file "' + filename + '" for which backup cannot be made: ' + str(e) + '\n')
  60. return False
  61. ok = False
  62. try:
  63. # Open the image
  64. with open(backupname, 'rb') as file:
  65. img = Image.open(file)
  66. # Check that it's a supported format
  67. format = str(img.format)
  68. if format != 'PNG' and format != 'JPEG':
  69. print 'Ignoring file "' + filename + '" with unsupported format ' + format
  70. return False
  71. # This line avoids problems that can arise saving larger JPEG files with PIL
  72. ImageFile.MAXBLOCK = img.size[0] * img.size[1]
  73. # The 'quality' option is ignored for PNG files
  74. img.save(filename, quality=90, optimize=True)
  75. # Check that we've actually made it smaller
  76. origsize = getsize(backupname)
  77. newsize = getsize(filename)
  78. if newsize >= origsize:
  79. print 'Cannot further compress "' + filename + '".'
  80. return False
  81. # Successful compression
  82. ok = True
  83. except Exception as e:
  84. stderr.write('Failure whilst processing "' + filename + '": ' + str(e) + '\n')
  85. finally:
  86. if not ok:
  87. try:
  88. move(backupname, filename)
  89. except Exception as e:
  90. stderr.write('ERROR: could not restore backup file for "' + filename + '": ' + str(e) + '\n')
  91. return ok
  92. class RestoreBackupImage(ProcessBase):
  93. """Processor which restores image from backup."""
  94. def __init__(self):
  95. ProcessBase.__init__(self)
  96. self.extensions = [self.backupextension]
  97. def processfile(self, filename):
  98. """Moves the backup file back to its original name."""
  99. try:
  100. move(filename, filename[: -(len(self.backupextension) + 1)])
  101. return True
  102. except Exception as e:
  103. stderr.write('Failed to restore backup file "' + filename + '": ' + str(e) + '\n')
  104. return False
  105. class DeleteBackupImage(ProcessBase):
  106. """Processor which deletes backup image."""
  107. def __init__(self):
  108. ProcessBase.__init__(self)
  109. self.extensions = [self.backupextension]
  110. def processfile(self, filename):
  111. """Deletes the specified file."""
  112. try:
  113. remove(filename)
  114. return True
  115. except Exception as e:
  116. stderr.write('Failed to delete backup file "' + filename + '": ' + str(e) + '\n')
  117. return False
  118. if __name__ == "__main__":
  119. # Argument parsing
  120. modecompress = 'compress'
  121. moderestorebackup = 'restorebackup'
  122. modedeletebackup = 'deletebackup'
  123. parser = ArgumentParser(description='Reduce file size of PNG and JPEG images.')
  124. parser.add_argument(
  125. 'path',
  126. help='File or directory name')
  127. parser.add_argument(
  128. '--mode', dest='mode', default=modecompress,
  129. choices=[modecompress, moderestorebackup, modedeletebackup],
  130. help='Mode to run with (default: ' + modecompress + '). '
  131. + modecompress + ': Compress the image(s). '
  132. + moderestorebackup + ': Restore the backup images (valid for directory path only). '
  133. + modedeletebackup + ': Delete the backup images (valid for directory path only).')
  134. args = parser.parse_args()
  135. # Construct processor requested mode
  136. if args.mode == modecompress:
  137. processor = CompressImage()
  138. elif args.mode == moderestorebackup:
  139. processor = RestoreBackupImage()
  140. elif args.mode == modedeletebackup:
  141. processor = DeleteBackupImage()
  142. # Run according to whether path is a file or a directory
  143. if isfile(args.path):
  144. if args.mode != modecompress:
  145. stderr.write('Mode "' + args.mode + '" supported on directories only.\n')
  146. exit(1)
  147. processor.processfile(args.path)
  148. elif isdir(args.path):
  149. filecount = processor.processdir(args.path)
  150. print '\nSuccessfully updated file count: ' + str(filecount)
  151. else:
  152. stderr.write('Invalid path "' + args.path + '"\n')
  153. exit(1)