Add priority to the celery tasks
[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
7f342c72
BP
17from __future__ import print_function
18
d0e9f843
AL
19try:
20 from PIL import Image
21except ImportError:
22 import Image
8e5f9746 23import os
92f129b5 24import logging
7ac66a3d 25import argparse
93bdab9d 26
e49b7e02
BP
27import six
28
93bdab9d 29from mediagoblin import mg_globals as mgg
c0434db4 30from mediagoblin.db.models import Location
85ead8ac
CAW
31from mediagoblin.processing import (
32 BadMediaFail, FilenameBuilder,
d1e9913b 33 MediaProcessor, ProcessingManager,
1cefccc7 34 request_from_args, get_process_filename,
5fd239fa 35 store_public, copy_original)
a180ca26 36from mediagoblin.tools.exif import exif_fix_image_orientation, \
0f8221dc
B
37 extract_exif, clean_exif, get_gps_data, get_useful, \
38 exif_image_needs_rotation
93bdab9d 39
92f129b5
JW
40_log = logging.getLogger(__name__)
41
d63cc34e
JW
42PIL_FILTERS = {
43 'NEAREST': Image.NEAREST,
44 'BILINEAR': Image.BILINEAR,
45 'BICUBIC': Image.BICUBIC,
46 'ANTIALIAS': Image.ANTIALIAS}
47
58a94757
RE
48MEDIA_TYPE = 'mediagoblin.media_types.image'
49
deea3f66 50
5fd239fa 51def resize_image(entry, resized, keyname, target_name, new_size,
a2f50198 52 exif_tags, workdir, quality, filter):
c72d661b
JW
53 """
54 Store a resized version of an image and return its pathname.
063670e9
BS
55
56 Arguments:
c82a8ba5 57 proc_state -- the processing state for the image to resize
3b359ddd 58 resized -- an image from Image.open() of the original image being resized
8f88b1f6
E
59 keyname -- Under what key to save in the db.
60 target_name -- public file path for the new resized image
063670e9
BS
61 exif_tags -- EXIF data for the original image
62 workdir -- directory path for storing converted image files
63 new_size -- 2-tuple size for the resized image
a2f50198
RE
64 quality -- level of compression used when resizing images
65 filter -- One of BICUBIC, BILINEAR, NEAREST, ANTIALIAS
063670e9 66 """
063670e9 67 resized = exif_fix_image_orientation(resized, exif_tags) # Fix orientation
7cd7db5a 68
7cd7db5a 69 try:
a2f50198 70 resize_filter = PIL_FILTERS[filter.upper()]
7cd7db5a
JW
71 except KeyError:
72 raise Exception('Filter "{0}" not found, choose one of {1}'.format(
e49b7e02 73 six.text_type(filter),
d63cc34e 74 u', '.join(PIL_FILTERS.keys())))
7cd7db5a
JW
75
76 resized.thumbnail(new_size, resize_filter)
063670e9 77
063670e9 78 # Copy the new file to the conversion subdir, then remotely.
8f88b1f6 79 tmp_resized_filename = os.path.join(workdir, target_name)
ea309bff 80 resized.save(tmp_resized_filename, quality=quality)
5fd239fa 81 store_public(entry, keyname, tmp_resized_filename, target_name)
063670e9 82
e2b56345
RE
83 # store the thumb/medium info
84 image_info = {'width': new_size[0],
85 'height': new_size[1],
86 'quality': quality,
87 'filter': filter}
88
89 entry.set_file_metadata(keyname, **image_info)
90
deea3f66 91
5b546d65 92def resize_tool(entry,
af51c423 93 force, keyname, orig_file, target_name,
a2f50198 94 conversions_subdir, exif_tags, quality, filter, new_size=None):
3e9faf85 95 # Use the default size if new_size was not given
9a2c66ca
RE
96 if not new_size:
97 max_width = mgg.global_config['media:' + keyname]['max_width']
98 max_height = mgg.global_config['media:' + keyname]['max_height']
99 new_size = (max_width, max_height)
3e9faf85 100
e2b56345
RE
101 # If thumb or medium is already the same quality and size, then don't
102 # reprocess
103 if _skip_resizing(entry, keyname, new_size, quality, filter):
104 _log.info('{0} of same size and quality already in use, skipping '
105 'resizing of media {1}.'.format(keyname, entry.id))
106 return
107
3b359ddd
E
108 # If the size of the original file exceeds the specified size for the desized
109 # file, a target_name file is created and later associated with the media
110 # entry.
111 # Also created if the file needs rotation, or if forced.
112 try:
af51c423 113 im = Image.open(orig_file)
3b359ddd
E
114 except IOError:
115 raise BadMediaFail()
116 if force \
49db7785
RE
117 or im.size[0] > new_size[0]\
118 or im.size[1] > new_size[1]\
3b359ddd 119 or exif_image_needs_rotation(exif_tags):
3b359ddd 120 resize_image(
e49b7e02 121 entry, im, six.text_type(keyname), target_name,
931fa43f 122 tuple(new_size),
a2f50198
RE
123 exif_tags, conversions_subdir,
124 quality, filter)
3b359ddd
E
125
126
e2b56345
RE
127def _skip_resizing(entry, keyname, size, quality, filter):
128 """
129 Determines wither the saved thumb or medium is of the same quality and size
130 """
131 image_info = entry.get_file_metadata(keyname)
132
133 if not image_info:
134 return False
135
136 skip = True
137
138 if image_info.get('width') != size[0]:
139 skip = False
140
141 elif image_info.get('height') != size[1]:
142 skip = False
143
144 elif image_info.get('filter') != filter:
145 skip = False
146
147 elif image_info.get('quality') != quality:
148 skip = False
149
150 return skip
151
152
b1a763f6 153SUPPORTED_FILETYPES = ['png', 'gif', 'jpg', 'jpeg', 'tiff']
92f129b5 154
c56d4b55 155
301da9ca 156def sniff_handler(media_file, filename):
58a94757 157 _log.info('Sniffing {0}'.format(MEDIA_TYPE))
301da9ca
CAW
158 name, ext = os.path.splitext(filename)
159 clean_ext = ext[1:].lower() # Strip the . from ext and make lowercase
160
161 if clean_ext in SUPPORTED_FILETYPES:
162 _log.info('Found file extension in supported filetypes')
163 return MEDIA_TYPE
92f129b5 164 else:
301da9ca
CAW
165 _log.debug('Media present, extension not found in {0}'.format(
166 SUPPORTED_FILETYPES))
92f129b5 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 226
4a09d595
JT
227 def extract_metadata(self, file):
228 """ Extract all the metadata from the image and store """
229 # Extract GPS data and store in Location
5b546d65 230 gps_data = get_gps_data(self.exif_tags)
5fd239fa 231
4a09d595
JT
232 if len(gps_data):
233 Location.create({"position": gps_data}, self.entry)
234
5fd239fa 235 # Insert exif data into database
5b546d65 236 exif_all = clean_exif(self.exif_tags)
5fd239fa
CAW
237
238 if len(exif_all):
239 self.entry.media_data_init(exif_all=exif_all)
240
4a09d595
JT
241 # Extract file metadata
242 try:
243 im = Image.open(self.process_filename)
244 except IOError:
245 raise BadMediaFail()
246
247 metadata = {
248 "width": im.size[0],
249 "height": im.size[1],
250 }
251
252 self.entry.set_file_metadata(file, **metadata)
2fa7b7f8 253
85ead8ac
CAW
254
255class InitialProcessor(CommonImageProcessor):
256 """
257 Initial processing step for new images
258 """
259 name = "initial"
260 description = "Initial processing"
261
262 @classmethod
7584080b 263 def media_is_eligible(cls, entry=None, state=None):
85ead8ac
CAW
264 """
265 Determine if this media type is eligible for processing
266 """
4a09d595
JT
267 if entry is None and state is None:
268 raise ValueError("Must specify either entry or state")
269
7584080b
RE
270 if not state:
271 state = entry.state
272 return state in (
85ead8ac
CAW
273 "unprocessed", "failed")
274
275 ###############################
276 # Command line interface things
277 ###############################
278
279 @classmethod
55a10fef 280 def generate_parser(cls):
85ead8ac 281 parser = argparse.ArgumentParser(
55a10fef
CAW
282 description=cls.description,
283 prog=cls.name)
85ead8ac 284
5b546d65
CAW
285 parser.add_argument(
286 '--size',
287 nargs=2,
288 metavar=('max_width', 'max_height'),
289 type=int)
290
291 parser.add_argument(
292 '--thumb-size',
293 nargs=2,
58350141 294 metavar=('max_width', 'max_height'),
5b546d65 295 type=int)
85ead8ac 296
a2f50198
RE
297 parser.add_argument(
298 '--filter',
299 choices=['BICUBIC', 'BILINEAR', 'NEAREST', 'ANTIALIAS'])
300
301 parser.add_argument(
302 '--quality',
303 type=int,
304 help='level of compression used when resizing images')
305
85ead8ac
CAW
306 return parser
307
308 @classmethod
55a10fef 309 def args_to_request(cls, args):
d1e9913b 310 return request_from_args(
a2f50198 311 args, ['size', 'thumb_size', 'filter', 'quality'])
85ead8ac 312
a2f50198 313 def process(self, size=None, thumb_size=None, quality=None, filter=None):
5fd239fa 314 self.common_setup()
a2f50198
RE
315 self.generate_medium_if_applicable(size=size, filter=filter,
316 quality=quality)
63021eb6 317 self.generate_thumb(size=thumb_size, filter=filter, quality=quality)
916db96e 318 self.copy_original()
4a09d595 319 self.extract_metadata('original')
58350141
RE
320 self.delete_queue_file()
321
322
323class Resizer(CommonImageProcessor):
324 """
325 Resizing process steps for processed media
326 """
327 name = 'resize'
328 description = 'Resize image'
3225008f 329 thumb_size = 'size'
58350141
RE
330
331 @classmethod
7584080b 332 def media_is_eligible(cls, entry=None, state=None):
58350141
RE
333 """
334 Determine if this media type is eligible for processing
335 """
4a09d595
JT
336 if entry is None and state is None:
337 raise ValueError("Must specify either entry or state")
338
7584080b
RE
339 if not state:
340 state = entry.state
341 return state in 'processed'
58350141
RE
342
343 ###############################
344 # Command line interface things
345 ###############################
346
347 @classmethod
348 def generate_parser(cls):
349 parser = argparse.ArgumentParser(
350 description=cls.description,
351 prog=cls.name)
352
353 parser.add_argument(
354 '--size',
355 nargs=2,
356 metavar=('max_width', 'max_height'),
357 type=int)
358
a2f50198
RE
359 parser.add_argument(
360 '--filter',
361 choices=['BICUBIC', 'BILINEAR', 'NEAREST', 'ANTIALIAS'])
362
363 parser.add_argument(
364 '--quality',
365 type=int,
366 help='level of compression used when resizing images')
367
58350141
RE
368 parser.add_argument(
369 'file',
370 choices=['medium', 'thumb'])
371
372 return parser
373
374 @classmethod
375 def args_to_request(cls, args):
376 return request_from_args(
a2f50198 377 args, ['size', 'file', 'quality', 'filter'])
58350141 378
a2f50198 379 def process(self, file, size=None, filter=None, quality=None):
58350141
RE
380 self.common_setup()
381 if file == 'medium':
a2f50198
RE
382 self.generate_medium_if_applicable(size=size, filter=filter,
383 quality=quality)
58350141 384 elif file == 'thumb':
a2f50198 385 self.generate_thumb(size=size, filter=filter, quality=quality)
2fa7b7f8 386
4a09d595
JT
387 self.extract_metadata(file)
388
389class MetadataProcessing(CommonImageProcessor):
390 """ Extraction and storage of media's metadata for processed images """
391
392 name = 'metadatabuilder'
393 description = 'Add or update image metadata'
394
395 @classmethod
396 def media_is_eligible(cls, entry=None, state=None):
397 if entry is None and state is None:
398 raise ValueError("Must specify either entry or state")
399
400 if state is None:
401 state = entry.state
402
403 return state == 'processed'
404
405 @classmethod
406 def generate_parser(cls):
407 parser = argparse.ArgumentParser(
408 description=cls.description,
409 prog=cls.name
410 )
411
412 parser.add_argument(
413 'file',
414 choices=['original', 'medium', 'thumb']
415 )
416
417 return parser
418
419 @classmethod
420 def args_to_request(cls, args):
421 return request_from_args(args, ['file'])
422
423 def process(self, file):
424 self.common_setup()
425 self.extract_metadata(file)
85ead8ac
CAW
426
427class ImageProcessingManager(ProcessingManager):
428 def __init__(self):
1a2982d6 429 super(ImageProcessingManager, self).__init__()
85ead8ac 430 self.add_processor(InitialProcessor)
58350141 431 self.add_processor(Resizer)
4a09d595 432 self.add_processor(MetadataProcessing)
85ead8ac 433
25ecdec9 434 def workflow(self, entry, manager, feed_url, reprocess_action,
435 reprocess_info=None):
c62181f4 436 ProcessMedia().apply_async(
437 [entry.id, feed_url, reprocess_action, reprocess_info], {},
438 task_id=entry.queued_task_id)
439
440
e8e444a8
JW
441if __name__ == '__main__':
442 import sys
443 import pprint
444
445 pp = pprint.PrettyPrinter()
446
447 result = extract_exif(sys.argv[1])
448 gps = get_gps_data(result)
a180ca26
JW
449 clean = clean_exif(result)
450 useful = get_useful(clean)
e8e444a8 451
7f342c72 452 print(pp.pprint(clean))