Merge remote-tracking branch 'refs/remotes/rodney757/reprocessing'
[mediagoblin.git] / mediagoblin / media_types / image / processing.py
CommitLineData
93bdab9d 1# GNU MediaGoblin -- federated, autonomous media hosting
cf29e8a8 2# Copyright (C) 2011, 2012 MediaGoblin contributors. See AUTHORS.
93bdab9d
JW
3#
4# This program is free software: you can redistribute it and/or modify
5# it under the terms of the GNU Affero General Public License as published by
6# the Free Software Foundation, either version 3 of the License, or
7# (at your option) any later version.
8#
9# This program is distributed in the hope that it will be useful,
10# but WITHOUT ANY WARRANTY; without even the implied warranty of
11# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12# GNU Affero General Public License for more details.
13#
14# You should have received a copy of the GNU Affero General Public License
15# along with this program. If not, see <http://www.gnu.org/licenses/>.
16
d0e9f843
AL
17try:
18 from PIL import Image
19except ImportError:
20 import Image
8e5f9746 21import os
92f129b5 22import logging
7ac66a3d 23import argparse
93bdab9d 24
93bdab9d 25from mediagoblin import mg_globals as mgg
85ead8ac
CAW
26from mediagoblin.processing import (
27 BadMediaFail, FilenameBuilder,
d1e9913b 28 MediaProcessor, ProcessingManager,
1cefccc7 29 request_from_args, get_process_filename,
5fd239fa 30 store_public, copy_original)
a180ca26 31from mediagoblin.tools.exif import exif_fix_image_orientation, \
0f8221dc
B
32 extract_exif, clean_exif, get_gps_data, get_useful, \
33 exif_image_needs_rotation
93bdab9d 34
92f129b5
JW
35_log = logging.getLogger(__name__)
36
d63cc34e
JW
37PIL_FILTERS = {
38 'NEAREST': Image.NEAREST,
39 'BILINEAR': Image.BILINEAR,
40 'BICUBIC': Image.BICUBIC,
41 'ANTIALIAS': Image.ANTIALIAS}
42
58a94757
RE
43MEDIA_TYPE = 'mediagoblin.media_types.image'
44
deea3f66 45
5fd239fa 46def resize_image(entry, resized, keyname, target_name, new_size,
a2f50198 47 exif_tags, workdir, quality, filter):
c72d661b
JW
48 """
49 Store a resized version of an image and return its pathname.
063670e9
BS
50
51 Arguments:
c82a8ba5 52 proc_state -- the processing state for the image to resize
3b359ddd 53 resized -- an image from Image.open() of the original image being resized
8f88b1f6
E
54 keyname -- Under what key to save in the db.
55 target_name -- public file path for the new resized image
063670e9
BS
56 exif_tags -- EXIF data for the original image
57 workdir -- directory path for storing converted image files
58 new_size -- 2-tuple size for the resized image
a2f50198
RE
59 quality -- level of compression used when resizing images
60 filter -- One of BICUBIC, BILINEAR, NEAREST, ANTIALIAS
063670e9 61 """
063670e9 62 resized = exif_fix_image_orientation(resized, exif_tags) # Fix orientation
7cd7db5a 63
7cd7db5a 64 try:
a2f50198 65 resize_filter = PIL_FILTERS[filter.upper()]
7cd7db5a
JW
66 except KeyError:
67 raise Exception('Filter "{0}" not found, choose one of {1}'.format(
a2f50198 68 unicode(filter),
d63cc34e 69 u', '.join(PIL_FILTERS.keys())))
7cd7db5a
JW
70
71 resized.thumbnail(new_size, resize_filter)
063670e9 72
063670e9 73 # Copy the new file to the conversion subdir, then remotely.
8f88b1f6 74 tmp_resized_filename = os.path.join(workdir, target_name)
063670e9 75 with file(tmp_resized_filename, 'w') as resized_file:
a2f50198 76 resized.save(resized_file, quality=quality)
5fd239fa 77 store_public(entry, keyname, tmp_resized_filename, target_name)
063670e9 78
e2b56345
RE
79 # store the thumb/medium info
80 image_info = {'width': new_size[0],
81 'height': new_size[1],
82 'quality': quality,
83 'filter': filter}
84
85 entry.set_file_metadata(keyname, **image_info)
86
deea3f66 87
5b546d65 88def resize_tool(entry,
af51c423 89 force, keyname, orig_file, target_name,
a2f50198 90 conversions_subdir, exif_tags, quality, filter, new_size=None):
3e9faf85 91 # Use the default size if new_size was not given
9a2c66ca
RE
92 if not new_size:
93 max_width = mgg.global_config['media:' + keyname]['max_width']
94 max_height = mgg.global_config['media:' + keyname]['max_height']
95 new_size = (max_width, max_height)
3e9faf85 96
e2b56345
RE
97 # If thumb or medium is already the same quality and size, then don't
98 # reprocess
99 if _skip_resizing(entry, keyname, new_size, quality, filter):
100 _log.info('{0} of same size and quality already in use, skipping '
101 'resizing of media {1}.'.format(keyname, entry.id))
102 return
103
3b359ddd
E
104 # If the size of the original file exceeds the specified size for the desized
105 # file, a target_name file is created and later associated with the media
106 # entry.
107 # Also created if the file needs rotation, or if forced.
108 try:
af51c423 109 im = Image.open(orig_file)
3b359ddd
E
110 except IOError:
111 raise BadMediaFail()
112 if force \
49db7785
RE
113 or im.size[0] > new_size[0]\
114 or im.size[1] > new_size[1]\
3b359ddd 115 or exif_image_needs_rotation(exif_tags):
3b359ddd 116 resize_image(
5fd239fa 117 entry, im, unicode(keyname), target_name,
931fa43f 118 tuple(new_size),
a2f50198
RE
119 exif_tags, conversions_subdir,
120 quality, filter)
3b359ddd
E
121
122
e2b56345
RE
123def _skip_resizing(entry, keyname, size, quality, filter):
124 """
125 Determines wither the saved thumb or medium is of the same quality and size
126 """
127 image_info = entry.get_file_metadata(keyname)
128
129 if not image_info:
130 return False
131
132 skip = True
133
134 if image_info.get('width') != size[0]:
135 skip = False
136
137 elif image_info.get('height') != size[1]:
138 skip = False
139
140 elif image_info.get('filter') != filter:
141 skip = False
142
143 elif image_info.get('quality') != quality:
144 skip = False
145
146 return skip
147
148
b1a763f6 149SUPPORTED_FILETYPES = ['png', 'gif', 'jpg', 'jpeg', 'tiff']
92f129b5 150
c56d4b55 151
ec4261a4 152def sniff_handler(media_file, **kw):
58a94757 153 _log.info('Sniffing {0}'.format(MEDIA_TYPE))
e2caf574 154 if kw.get('media') is not None: # That's a double negative!
92f129b5
JW
155 name, ext = os.path.splitext(kw['media'].filename)
156 clean_ext = ext[1:].lower() # Strip the . from ext and make lowercase
157
92f129b5
JW
158 if clean_ext in SUPPORTED_FILETYPES:
159 _log.info('Found file extension in supported filetypes')
58a94757 160 return MEDIA_TYPE
92f129b5 161 else:
10085b77 162 _log.debug('Media present, extension not found in {0}'.format(
92f129b5
JW
163 SUPPORTED_FILETYPES))
164 else:
165 _log.warning('Need additional information (keyword argument \'media\')'
166 ' to be able to handle sniffing')
167
58a94757 168 return None
ec4261a4 169
c56d4b55 170
85ead8ac
CAW
171class CommonImageProcessor(MediaProcessor):
172 """
173 Provides a base for various media processing steps
174 """
1cefccc7
RE
175 # list of acceptable file keys in order of prefrence for reprocessing
176 acceptable_files = ['original', 'medium']
177
5fd239fa
CAW
178 def common_setup(self):
179 """
180 Set up the workbench directory and pull down the original file
181 """
93874d0a
RE
182 self.image_config = mgg.global_config['plugins'][
183 'mediagoblin.media_types.image']
a2f50198 184
5fd239fa 185 ## @@: Should this be two functions?
eb372949
CAW
186 # Conversions subdirectory to avoid collisions
187 self.conversions_subdir = os.path.join(
0485e9c8 188 self.workbench.dir, 'conversions')
5fd239fa 189 os.mkdir(self.conversions_subdir)
eb372949 190
1cefccc7
RE
191 # Pull down and set up the processing file
192 self.process_filename = get_process_filename(
193 self.entry, self.workbench, self.acceptable_files)
194 self.name_builder = FilenameBuilder(self.process_filename)
2fa7b7f8 195
5b546d65 196 # Exif extraction
1cefccc7 197 self.exif_tags = extract_exif(self.process_filename)
5b546d65 198
a2f50198
RE
199 def generate_medium_if_applicable(self, size=None, quality=None,
200 filter=None):
201 if not quality:
202 quality = self.image_config['quality']
203 if not filter:
204 filter = self.image_config['resize_filter']
205
1cefccc7 206 resize_tool(self.entry, False, 'medium', self.process_filename,
5fd239fa 207 self.name_builder.fill('{basename}.medium{ext}'),
a2f50198
RE
208 self.conversions_subdir, self.exif_tags, quality,
209 filter, size)
210
211 def generate_thumb(self, size=None, quality=None, filter=None):
212 if not quality:
213 quality = self.image_config['quality']
214 if not filter:
63021eb6 215 filter = self.image_config['resize_filter']
2fa7b7f8 216
1cefccc7 217 resize_tool(self.entry, True, 'thumb', self.process_filename,
5fd239fa 218 self.name_builder.fill('{basename}.thumbnail{ext}'),
a2f50198
RE
219 self.conversions_subdir, self.exif_tags, quality,
220 filter, size)
5fd239fa
CAW
221
222 def copy_original(self):
223 copy_original(
1cefccc7 224 self.entry, self.process_filename,
5fd239fa 225 self.name_builder.fill('{basename}{ext}'))
2fa7b7f8
CAW
226
227 def extract_metadata(self):
5fd239fa 228 # Is there any GPS data
5b546d65 229 gps_data = get_gps_data(self.exif_tags)
5fd239fa
CAW
230
231 # Insert exif data into database
5b546d65 232 exif_all = clean_exif(self.exif_tags)
5fd239fa
CAW
233
234 if len(exif_all):
235 self.entry.media_data_init(exif_all=exif_all)
236
237 if len(gps_data):
238 for key in list(gps_data.keys()):
239 gps_data['gps_' + key] = gps_data.pop(key)
240 self.entry.media_data_init(**gps_data)
2fa7b7f8 241
85ead8ac
CAW
242
243class InitialProcessor(CommonImageProcessor):
244 """
245 Initial processing step for new images
246 """
247 name = "initial"
248 description = "Initial processing"
249
250 @classmethod
7584080b 251 def media_is_eligible(cls, entry=None, state=None):
85ead8ac
CAW
252 """
253 Determine if this media type is eligible for processing
254 """
7584080b
RE
255 if not state:
256 state = entry.state
257 return state in (
85ead8ac
CAW
258 "unprocessed", "failed")
259
260 ###############################
261 # Command line interface things
262 ###############################
263
264 @classmethod
55a10fef 265 def generate_parser(cls):
85ead8ac 266 parser = argparse.ArgumentParser(
55a10fef
CAW
267 description=cls.description,
268 prog=cls.name)
85ead8ac 269
5b546d65
CAW
270 parser.add_argument(
271 '--size',
272 nargs=2,
273 metavar=('max_width', 'max_height'),
274 type=int)
275
276 parser.add_argument(
277 '--thumb-size',
278 nargs=2,
58350141 279 metavar=('max_width', 'max_height'),
5b546d65 280 type=int)
85ead8ac 281
a2f50198
RE
282 parser.add_argument(
283 '--filter',
284 choices=['BICUBIC', 'BILINEAR', 'NEAREST', 'ANTIALIAS'])
285
286 parser.add_argument(
287 '--quality',
288 type=int,
289 help='level of compression used when resizing images')
290
85ead8ac
CAW
291 return parser
292
293 @classmethod
55a10fef 294 def args_to_request(cls, args):
d1e9913b 295 return request_from_args(
a2f50198 296 args, ['size', 'thumb_size', 'filter', 'quality'])
85ead8ac 297
a2f50198 298 def process(self, size=None, thumb_size=None, quality=None, filter=None):
5fd239fa 299 self.common_setup()
a2f50198
RE
300 self.generate_medium_if_applicable(size=size, filter=filter,
301 quality=quality)
63021eb6 302 self.generate_thumb(size=thumb_size, filter=filter, quality=quality)
916db96e 303 self.copy_original()
2fa7b7f8 304 self.extract_metadata()
58350141
RE
305 self.delete_queue_file()
306
307
308class Resizer(CommonImageProcessor):
309 """
310 Resizing process steps for processed media
311 """
312 name = 'resize'
313 description = 'Resize image'
3225008f 314 thumb_size = 'size'
58350141
RE
315
316 @classmethod
7584080b 317 def media_is_eligible(cls, entry=None, state=None):
58350141
RE
318 """
319 Determine if this media type is eligible for processing
320 """
7584080b
RE
321 if not state:
322 state = entry.state
323 return state in 'processed'
58350141
RE
324
325 ###############################
326 # Command line interface things
327 ###############################
328
329 @classmethod
330 def generate_parser(cls):
331 parser = argparse.ArgumentParser(
332 description=cls.description,
333 prog=cls.name)
334
335 parser.add_argument(
336 '--size',
337 nargs=2,
338 metavar=('max_width', 'max_height'),
339 type=int)
340
a2f50198
RE
341 parser.add_argument(
342 '--filter',
343 choices=['BICUBIC', 'BILINEAR', 'NEAREST', 'ANTIALIAS'])
344
345 parser.add_argument(
346 '--quality',
347 type=int,
348 help='level of compression used when resizing images')
349
58350141
RE
350 parser.add_argument(
351 'file',
352 choices=['medium', 'thumb'])
353
354 return parser
355
356 @classmethod
357 def args_to_request(cls, args):
358 return request_from_args(
a2f50198 359 args, ['size', 'file', 'quality', 'filter'])
58350141 360
a2f50198 361 def process(self, file, size=None, filter=None, quality=None):
58350141
RE
362 self.common_setup()
363 if file == 'medium':
a2f50198
RE
364 self.generate_medium_if_applicable(size=size, filter=filter,
365 quality=quality)
58350141 366 elif file == 'thumb':
a2f50198 367 self.generate_thumb(size=size, filter=filter, quality=quality)
2fa7b7f8 368
85ead8ac
CAW
369
370class ImageProcessingManager(ProcessingManager):
371 def __init__(self):
372 super(self.__class__, self).__init__()
373 self.add_processor(InitialProcessor)
58350141 374 self.add_processor(Resizer)
85ead8ac
CAW
375
376
e8e444a8
JW
377if __name__ == '__main__':
378 import sys
379 import pprint
380
381 pp = pprint.PrettyPrinter()
382
383 result = extract_exif(sys.argv[1])
384 gps = get_gps_data(result)
a180ca26
JW
385 clean = clean_exif(result)
386 useful = get_useful(clean)
e8e444a8 387
e8e444a8 388 print pp.pprint(
a180ca26 389 clean)