14091d6ec16a89a0dae3b4ecb97579fbf129d7d1
[mediagoblin.git] / mediagoblin / media_types / image / processing.py
1 # GNU MediaGoblin -- federated, autonomous media hosting
2 # Copyright (C) 2011, 2012 MediaGoblin contributors. See AUTHORS.
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
17 from __future__ import print_function
18
19 try:
20 from PIL import Image
21 except ImportError:
22 import Image
23 import os
24 import logging
25 import argparse
26
27 import six
28
29 from mediagoblin import mg_globals as mgg
30 from mediagoblin.db.models import Location
31 from mediagoblin.processing import (
32 BadMediaFail, FilenameBuilder,
33 MediaProcessor, ProcessingManager,
34 request_from_args, get_process_filename,
35 store_public, copy_original)
36 from mediagoblin.tools.exif import exif_fix_image_orientation, \
37 extract_exif, clean_exif, get_gps_data, get_useful, \
38 exif_image_needs_rotation
39
40 _log = logging.getLogger(__name__)
41
42 PIL_FILTERS = {
43 'NEAREST': Image.NEAREST,
44 'BILINEAR': Image.BILINEAR,
45 'BICUBIC': Image.BICUBIC,
46 'ANTIALIAS': Image.ANTIALIAS}
47
48 MEDIA_TYPE = 'mediagoblin.media_types.image'
49
50
51 def resize_image(entry, resized, keyname, target_name, new_size,
52 exif_tags, workdir, quality, filter):
53 """
54 Store a resized version of an image and return its pathname.
55
56 Arguments:
57 proc_state -- the processing state for the image to resize
58 resized -- an image from Image.open() of the original image being resized
59 keyname -- Under what key to save in the db.
60 target_name -- public file path for the new resized image
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
64 quality -- level of compression used when resizing images
65 filter -- One of BICUBIC, BILINEAR, NEAREST, ANTIALIAS
66 """
67 resized = exif_fix_image_orientation(resized, exif_tags) # Fix orientation
68
69 try:
70 resize_filter = PIL_FILTERS[filter.upper()]
71 except KeyError:
72 raise Exception('Filter "{0}" not found, choose one of {1}'.format(
73 six.text_type(filter),
74 u', '.join(PIL_FILTERS.keys())))
75
76 resized.thumbnail(new_size, resize_filter)
77
78 # Copy the new file to the conversion subdir, then remotely.
79 tmp_resized_filename = os.path.join(workdir, target_name)
80 resized.save(tmp_resized_filename, quality=quality)
81 store_public(entry, keyname, tmp_resized_filename, target_name)
82
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
91
92 def resize_tool(entry,
93 force, keyname, orig_file, target_name,
94 conversions_subdir, exif_tags, quality, filter, new_size=None):
95 # Use the default size if new_size was not given
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)
100
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
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:
113 im = Image.open(orig_file)
114 except IOError:
115 raise BadMediaFail()
116 if force \
117 or im.size[0] > new_size[0]\
118 or im.size[1] > new_size[1]\
119 or exif_image_needs_rotation(exif_tags):
120 resize_image(
121 entry, im, six.text_type(keyname), target_name,
122 tuple(new_size),
123 exif_tags, conversions_subdir,
124 quality, filter)
125
126
127 def _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
153 SUPPORTED_FILETYPES = ['png', 'gif', 'jpg', 'jpeg', 'tiff']
154
155
156 def sniff_handler(media_file, filename):
157 _log.info('Sniffing {0}'.format(MEDIA_TYPE))
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
164 else:
165 _log.debug('Media present, extension not found in {0}'.format(
166 SUPPORTED_FILETYPES))
167
168 return None
169
170
171 class CommonImageProcessor(MediaProcessor):
172 """
173 Provides a base for various media processing steps
174 """
175 # list of acceptable file keys in order of prefrence for reprocessing
176 acceptable_files = ['original', 'medium']
177
178 def common_setup(self):
179 """
180 Set up the workbench directory and pull down the original file
181 """
182 self.image_config = mgg.global_config['plugins'][
183 'mediagoblin.media_types.image']
184
185 ## @@: Should this be two functions?
186 # Conversions subdirectory to avoid collisions
187 self.conversions_subdir = os.path.join(
188 self.workbench.dir, 'conversions')
189 os.mkdir(self.conversions_subdir)
190
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)
195
196 # Exif extraction
197 self.exif_tags = extract_exif(self.process_filename)
198
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
206 resize_tool(self.entry, False, 'medium', self.process_filename,
207 self.name_builder.fill('{basename}.medium{ext}'),
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:
215 filter = self.image_config['resize_filter']
216
217 resize_tool(self.entry, True, 'thumb', self.process_filename,
218 self.name_builder.fill('{basename}.thumbnail{ext}'),
219 self.conversions_subdir, self.exif_tags, quality,
220 filter, size)
221
222 def copy_original(self):
223 copy_original(
224 self.entry, self.process_filename,
225 self.name_builder.fill('{basename}{ext}'))
226
227 def extract_metadata(self, file):
228 """ Extract all the metadata from the image and store """
229 # Extract GPS data and store in Location
230 gps_data = get_gps_data(self.exif_tags)
231
232 if len(gps_data):
233 Location.create({"position": gps_data}, self.entry)
234
235 # Insert exif data into database
236 exif_all = clean_exif(self.exif_tags)
237
238 if len(exif_all):
239 self.entry.media_data_init(exif_all=exif_all)
240
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)
253
254
255 class InitialProcessor(CommonImageProcessor):
256 """
257 Initial processing step for new images
258 """
259 name = "initial"
260 description = "Initial processing"
261
262 @classmethod
263 def media_is_eligible(cls, entry=None, state=None):
264 """
265 Determine if this media type is eligible for processing
266 """
267 if entry is None and state is None:
268 raise ValueError("Must specify either entry or state")
269
270 if not state:
271 state = entry.state
272 return state in (
273 "unprocessed", "failed")
274
275 ###############################
276 # Command line interface things
277 ###############################
278
279 @classmethod
280 def generate_parser(cls):
281 parser = argparse.ArgumentParser(
282 description=cls.description,
283 prog=cls.name)
284
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,
294 metavar=('max_width', 'max_height'),
295 type=int)
296
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
306 return parser
307
308 @classmethod
309 def args_to_request(cls, args):
310 return request_from_args(
311 args, ['size', 'thumb_size', 'filter', 'quality'])
312
313 def process(self, size=None, thumb_size=None, quality=None, filter=None):
314 self.common_setup()
315 self.generate_medium_if_applicable(size=size, filter=filter,
316 quality=quality)
317 self.generate_thumb(size=thumb_size, filter=filter, quality=quality)
318 self.copy_original()
319 self.extract_metadata('original')
320 self.delete_queue_file()
321
322
323 class Resizer(CommonImageProcessor):
324 """
325 Resizing process steps for processed media
326 """
327 name = 'resize'
328 description = 'Resize image'
329 thumb_size = 'size'
330
331 @classmethod
332 def media_is_eligible(cls, entry=None, state=None):
333 """
334 Determine if this media type is eligible for processing
335 """
336 if entry is None and state is None:
337 raise ValueError("Must specify either entry or state")
338
339 if not state:
340 state = entry.state
341 return state in 'processed'
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
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
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(
377 args, ['size', 'file', 'quality', 'filter'])
378
379 def process(self, file, size=None, filter=None, quality=None):
380 self.common_setup()
381 if file == 'medium':
382 self.generate_medium_if_applicable(size=size, filter=filter,
383 quality=quality)
384 elif file == 'thumb':
385 self.generate_thumb(size=size, filter=filter, quality=quality)
386
387 self.extract_metadata(file)
388
389 class 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)
426
427 class ImageProcessingManager(ProcessingManager):
428 def __init__(self):
429 super(ImageProcessingManager, self).__init__()
430 self.add_processor(InitialProcessor)
431 self.add_processor(Resizer)
432 self.add_processor(MetadataProcessing)
433
434 if __name__ == '__main__':
435 import sys
436 import pprint
437
438 pp = pprint.PrettyPrinter()
439
440 result = extract_exif(sys.argv[1])
441 gps = get_gps_data(result)
442 clean = clean_exif(result)
443 useful = get_useful(clean)
444
445 print(pp.pprint(clean))