1
1
#!/usr/bin/python3
2
2
3
3
"""
4
- Process all images inside a directory: resize them, convert to different formats,
5
- remove metadata, add watermarks and compress it all nicely for upload or share
6
- online.
4
+ Process images by converting them to different formats, resizing them and store
5
+ them as compressed archives for easy uplaod to your cloud. You may provide one
6
+ or more images and they'll be processed in parallel for extra speed.
7
+
8
+ usage: imgtest.py [-h] [-v] [-o [OUTPUT]] [-r WIDTH HEIGHT]
9
+ [-f {webp,jpeg,jpg,png} [{webp,jpeg,jpg,png} ...]]
10
+ [-z | -t [ARCHIVE]]
11
+ input [input ...]
12
+
13
+ positional arguments:
14
+ input A single or multiple image files to process
15
+
16
+ optional arguments:
17
+ -h, --help show this help message and exit
18
+ -v, --verbose Writes output to the terminal as the program runs
19
+ -o [OUTPUT], --output [OUTPUT]
20
+ Directory where processed images will be saved to
21
+ -r WIDTH HEIGHT, --resize WIDTH HEIGHT
22
+ Pixel width and height of the output image (preserves
23
+ aspect-ratio)
24
+ -f {webp,jpeg,jpg,png} [{webp,jpeg,jpg,png} ...], --format {webp,jpeg,jpg,png} [{webp,jpeg,jpg,png} ...]
25
+ Convert processed file to the selected image format
26
+ -z, --zip-archive Store processed images as .zip archive (uncompressed)
27
+ -t [ARCHIVE], --tar-archive [ARCHIVE]
28
+ Store processed images as .tar.gz archive (compressed)
7
29
8
- USAGE: resizer.py INPUT_FOLDER [OPTIONS]
9
-
10
- TODO: Option to convert to multiple image formats and add support for more formats.
11
30
TODO: Option to provide an image to add as a watermark for each image
12
- TODO: Option to preserve original metadata into the processed image
13
- TODO: Option to generate each output image's name using image recognition
14
31
"""
15
32
16
33
from PIL import Image
17
34
from itertools import repeat
18
- from pathlib import Path
19
35
import concurrent .futures
20
36
import argparse
21
- import zipfile
37
+ import logging
22
38
import tarfile
23
- import hashlib
24
- import shutil
25
- import sys
26
- import os
39
+ import zipfile
27
40
import re
28
- import logging
29
-
30
- def main (args ):
41
+ import os
31
42
43
+ def main ():
32
44
args = parse_arguments ()
33
- if args .verbose == 1 :
34
- logging .basicConfig (level = logging .INFO )
35
45
36
- # What files will be processed
37
- valid_files = re .compile (r'.*\.(jpg|jpeg|png|webp)$' , re .IGNORECASE )
38
- images = [file for file in os .listdir (args .input_dir ) if valid_files .match (file )]
46
+ valid_ext = re .compile (r'.*\.(jpe?g|png|webp)$' , re .IGNORECASE )
47
+ images = [file for file in args .input if valid_ext .match (file )]
39
48
40
- # For logging purposes only
41
- get_images_size = (os .path .getsize (args .input_dir / img ) for img in images )
42
- logging .info (f'{ len (images )} images found ({ round (sum (get_images_size ) / 1000000 , 2 )} Mb...)' )
49
+ if args .verbose :
50
+ logging .basicConfig (level = logging .DEBUG )
51
+ print ('Dimensions:' , args .dimensions )
52
+ print ('Output:' , args .output )
53
+ print ('Convert to:' , args .format )
54
+ print (f'Found { len (images )} images:' )
43
55
44
- # Process each file using multi-processing
56
+ # Process each file using multi-processing, returns extensions used
45
57
with concurrent .futures .ProcessPoolExecutor () as executor :
46
- executor .map (process_image , images , repeat (args ))
58
+ formats = executor .map (process_image , images , repeat (args ))
47
59
48
- # If an archive flag was passed, make an archive for all the files
60
+ # Creates an archive with the output images.
49
61
if args .archive :
50
- make_archive (args )
51
-
52
- # If the delete flag was passed, delete processed files
53
- if args .delete_original :
54
- delete_processed (images , args )
55
-
56
-
57
- def process_image (file , args ):
58
-
59
- '''Resizes the image, saves it and if specified it renames it using a hash
60
- function to randomize its name'''
61
-
62
- logging .info (f'Processing { file } ...' )
63
-
64
- filename , _ = os .path .splitext (file )
65
- with Image .open (args .input_dir / file ) as img :
66
- # Get relevant data for the new image
67
- width = args .dimensions [0 ] if args .dimensions else img .width
68
- height = args .dimensions [1 ] if args .dimensions else img .height
69
- format = args .format if args .format else img .format
70
-
71
- # Obscure filename with a hashing function
72
- if args .hash_names :
73
- logging .info (f'Hashing filename...' )
74
- filename = hashlib .sha1 (file .encode ('utf-8' )).hexdigest ()
75
-
76
- # Create new image file with specified dimensions
77
- img .thumbnail ((width , height ))
78
- img .save (f'{ args .output_dir / filename } .{ format .lower ()} ' , format .upper ())
79
-
80
-
81
- def make_archive (args ):
82
-
83
- '''Creates an archive of the specified format to store all processed images.'''
84
-
85
- archive_location = args .output_dir .parent
62
+ make_archive (args , images , next (formats ))
86
63
87
- # Create archive in tar format
88
- if args .archive == 'gz' :
89
- with tarfile .open (archive_location / 'processed_images.tar.gz' , 'x:gz' ) as tar :
90
- tar .add (args .output_dir , arcname = 'images' )
91
64
92
- # Create archive in zip format
93
- if args .archive == 'ZIP_STORED' :
94
- with zipfile .ZipFile (archive_location / 'processed_images.zip' , 'w' ) as zip :
95
- os .chdir (args .output_dir )
96
- for file in os .listdir (args .output_dir ):
97
- zip .write (file )
65
+ def make_archive (args , input , formats ):
66
+ '''Creates an archive of the specified format to store output images.'''
98
67
68
+ # Store a list of image filenames without their full path or file format
69
+ images = [os .path .splitext (i )[0 ].split (os .path .sep )[- 1 ] for i in input ]
99
70
100
- # Once archive is created we can delete the output directory
101
- logging .info (f'Archive created, removing output directory...' )
102
- shutil .rmtree (args .output_dir )
71
+ # Store the file extensions used for this script execution
72
+ formats = set ([format .replace ('jpg' , 'jpeg' ) for format in formats ])
103
73
74
+ archive_name = os .path .join (args .output , f'{ args .archive } .tar.gz' )
104
75
105
- def delete_processed ( images , args ):
76
+ logging . debug ( f'Building archive { archive_name } ...' )
106
77
107
- '''Deletes all files that were processed'''
78
+ with tarfile .open (archive_name , 'x:gz' ) as tar :
79
+ for img in images :
80
+ for ext in formats :
81
+ file_to_add = f'{ args .output } { os .path .sep } resized-{ img } .{ ext } '
82
+ tar .add (file_to_add , arcname = f'resized-{ img } .{ ext } ' )
108
83
109
- logging .info (f'Deleting originals...' )
110
- for img in images :
111
- os .unlink (args .input_dir / img )
112
84
85
+ def process_image (image , args ):
86
+ '''Takes an image file path and process it according to provided arguments'''
113
87
114
- def get_input_directory (path ):
88
+ filename , ext = os .path .splitext (os .path .basename (image ))
89
+ formats = args .format if args .format else [ext .replace ('.' , '' )]
115
90
116
- '''Verifies the path provided and returns it as a Path object'''
91
+ # Append short hash to filename to avoid accidental overwrite
92
+ filename = f'resized-{ filename } '
117
93
118
- p = Path (path )
119
- if p .exists () and p .is_dir ():
120
- return p .resolve ()
121
- else :
122
- msg = f'{ path } is not a valid directory or does not exist'
123
- raise argparse .ArgumentTypeError (msg )
94
+ logging .debug (f'Processing file { filename } ' )
124
95
96
+ with Image .open (image ) as img :
97
+ # Find width, height and output file format
98
+ width = args .dimensions [0 ] if args .dimensions else img .width
99
+ height = args .dimensions [1 ] if args .dimensions else img .height
125
100
126
- def get_output_directory (path ):
127
-
128
- '''Verifies the path provided and returns it as a Path object. It'll
129
- create any necessary directories if they don't exist'''
101
+ # Resize image to specified dimensions
102
+ img .thumbnail ((width , height ))
130
103
131
- p = Path (path ) / 'processed_images'
132
- if p .exists () and p .is_dir ():
133
- return p .resolve ()
134
- elif p .exists () and not p .is_dir ():
135
- msg = f'{ path } is not a valid directory'
136
- raise argparse .ArgumentTypeError (msg )
137
- else :
138
- dir = p .resolve ()
139
- os .makedirs (dir )
140
- return dir
104
+ # Save image for each specified format
105
+ for f in formats :
106
+ format = 'JPEG' if f .lower ().endswith ('jpg' ) else f .upper ()
107
+ filepath = f'{ os .path .join (args .output , filename )} .{ format .lower ()} '
108
+ img .save (filepath , format )
141
109
110
+ return formats
142
111
143
- def get_watermark_image (path ):
144
112
145
- '''Verifies the path provided and returns an Image object'''
113
+ def parse_arguments ():
114
+ '''Parses the arguments used to call this script'''
146
115
147
- p = Path (path )
148
- if p .exists () and p .is_file ():
149
- with Image .open (p .resolve ()) as img :
150
- return img
151
- else :
152
- msg = f'{ path } is not a valid file or does not exist'
153
- raise argparse .ArgumentTypeError (msg )
116
+ parser = argparse .ArgumentParser ()
154
117
155
- def parse_arguments ():
156
- parser = argparse . ArgumentParser (
157
- description = 'Bulk image processor - resize, convert and organize your images '
118
+ parser . add_argument ( '-v' , '--verbose' ,
119
+ help = 'Writes output to the terminal as the program runs' ,
120
+ action = 'store_true '
158
121
)
159
- parser .add_argument ('input_dir' ,
160
- help = 'Directory location with all images' ,
161
- metavar = 'input_file' ,
162
- type = get_input_directory
122
+ parser .add_argument ('input' ,
123
+ help = 'A single or multiple image files to process' ,
124
+ nargs = '+'
163
125
)
164
126
parser .add_argument ('-o' , '--output' ,
165
- help = 'Choose the location for output images (defaults to current directory)' ,
166
- dest = 'output_dir' ,
167
- metavar = 'output_dir' ,
168
- type = get_output_directory ,
127
+ help = 'Directory where processed images will be saved to' ,
128
+ nargs = '?' ,
169
129
default = os .getcwd ()
170
130
)
171
131
parser .add_argument ('-r' , '--resize' ,
172
- help = 'Resize images to width and height, preserves aspect-ratio' ,
173
- dest = 'dimensions' ,
174
- metavar = ('width' , 'height' ),
132
+ help = 'Pixel width and height of the output image (preserves aspect-ratio)' ,
133
+ nargs = 2 ,
175
134
type = int ,
176
- nargs = 2
135
+ metavar = ('WIDTH' , 'HEIGHT' ),
136
+ dest = 'dimensions'
177
137
)
178
138
parser .add_argument ('-f' , '--format' ,
179
- help = 'Converts image to specified format' ,
180
- dest = 'format' ,
181
- choices = ['jpg' , 'jpeg' , 'png' , 'webp' ]
182
- )
183
- parser .add_argument ('-m' , '--metadata' ,
184
- help = "Preserves the original image's metadata" ,
185
- dest = 'metadata' ,
186
- action = 'store_true'
187
- )
188
- parser .add_argument ('-u' , '--use-hash' ,
189
- help = 'Obscure output filenames using a hashing function' ,
190
- dest = 'hash_names' ,
191
- action = 'store_true'
192
- )
193
- parser .add_argument ('-d' , '--delete' ,
194
- help = 'Deletes original images upon completion' ,
195
- dest = 'delete_original' ,
196
- action = 'store_true'
197
- )
198
- parser .add_argument ('-w' , '--watermark' ,
199
- help = 'Blends the provided image into the processed output' ,
200
- dest = 'watermark' ,
201
- metavar = 'image_file' ,
202
- type = get_watermark_image
203
- )
204
- parser .add_argument ('-v' , '--verbose' ,
205
- help = 'Writes output to the terminal as the program runs' ,
206
- dest = 'verbose' ,
207
- action = 'count' ,
208
- default = 0
139
+ help = 'Convert processed file to the selected image format' ,
140
+ nargs = '+' ,
141
+ action = 'store' ,
142
+ choices = ['webp' , 'jpeg' , 'jpg' , 'png' ]
209
143
)
210
144
archive_options = parser .add_mutually_exclusive_group ()
211
145
archive_options .add_argument ('-z' , '--zip-archive' ,
@@ -217,11 +151,11 @@ def parse_arguments():
217
151
archive_options .add_argument ('-t' , '--tar-archive' ,
218
152
help = 'Store processed images as .tar.gz archive (compressed)' ,
219
153
dest = 'archive' ,
220
- action = 'store_const ' ,
221
- const = 'gz '
154
+ nargs = '? ' ,
155
+ const = 'output '
222
156
)
223
-
224
157
return parser .parse_args ()
225
158
159
+
226
160
if __name__ == '__main__' :
227
161
main ()
0 commit comments