Should be enough to get to the point where you can actually initialize a processing...
[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 try:
18 from PIL import Image
19 except ImportError:
20 import Image
21 import os
22 import logging
23 import argparse
24
25 from mediagoblin import mg_globals as mgg
26 from mediagoblin.db.models import MediaEntry
27 from mediagoblin.processing import (
28 BadMediaFail, FilenameBuilder,
29 MediaProcessor, ProcessingManager,
30 request_from_args)
31 from mediagoblin.submit.lib import run_process_media
32 from mediagoblin.tools.exif import exif_fix_image_orientation, \
33 extract_exif, clean_exif, get_gps_data, get_useful, \
34 exif_image_needs_rotation
35 from mediagoblin.tools.translate import lazy_pass_to_ugettext as _
36
37 _log = logging.getLogger(__name__)
38
39 PIL_FILTERS = {
40 'NEAREST': Image.NEAREST,
41 'BILINEAR': Image.BILINEAR,
42 'BICUBIC': Image.BICUBIC,
43 'ANTIALIAS': Image.ANTIALIAS}
44
45 MEDIA_TYPE = 'mediagoblin.media_types.image'
46
47
48 def resize_image(proc_state, resized, keyname, target_name, new_size,
49 exif_tags, workdir):
50 """
51 Store a resized version of an image and return its pathname.
52
53 Arguments:
54 proc_state -- the processing state for the image to resize
55 resized -- an image from Image.open() of the original image being resized
56 keyname -- Under what key to save in the db.
57 target_name -- public file path for the new resized image
58 exif_tags -- EXIF data for the original image
59 workdir -- directory path for storing converted image files
60 new_size -- 2-tuple size for the resized image
61 """
62 config = mgg.global_config['media_type:mediagoblin.media_types.image']
63
64 resized = exif_fix_image_orientation(resized, exif_tags) # Fix orientation
65
66 filter_config = config['resize_filter']
67 try:
68 resize_filter = PIL_FILTERS[filter_config.upper()]
69 except KeyError:
70 raise Exception('Filter "{0}" not found, choose one of {1}'.format(
71 unicode(filter_config),
72 u', '.join(PIL_FILTERS.keys())))
73
74 resized.thumbnail(new_size, resize_filter)
75
76 # Copy the new file to the conversion subdir, then remotely.
77 tmp_resized_filename = os.path.join(workdir, target_name)
78 with file(tmp_resized_filename, 'w') as resized_file:
79 resized.save(resized_file, quality=config['quality'])
80 proc_state.store_public(keyname, tmp_resized_filename, target_name)
81
82
83 def resize_tool(proc_state, force, keyname, target_name,
84 conversions_subdir, exif_tags, new_size=None):
85 # filename -- the filename of the original image being resized
86 filename = proc_state.get_orig_filename()
87
88 # Use the default size if new_size was not given
89 if not new_size:
90 max_width = mgg.global_config['media:' + keyname]['max_width']
91 max_height = mgg.global_config['media:' + keyname]['max_height']
92 new_size = (max_width, max_height)
93
94 # If the size of the original file exceeds the specified size for the desized
95 # file, a target_name file is created and later associated with the media
96 # entry.
97 # Also created if the file needs rotation, or if forced.
98 try:
99 im = Image.open(filename)
100 except IOError:
101 raise BadMediaFail()
102 if force \
103 or im.size[0] > new_size[0]\
104 or im.size[1] > new_size[1]\
105 or exif_image_needs_rotation(exif_tags):
106 resize_image(
107 proc_state, im, unicode(keyname), target_name,
108 new_size,
109 exif_tags, conversions_subdir)
110
111
112 SUPPORTED_FILETYPES = ['png', 'gif', 'jpg', 'jpeg', 'tiff']
113
114
115 def sniff_handler(media_file, **kw):
116 _log.info('Sniffing {0}'.format(MEDIA_TYPE))
117 if kw.get('media') is not None: # That's a double negative!
118 name, ext = os.path.splitext(kw['media'].filename)
119 clean_ext = ext[1:].lower() # Strip the . from ext and make lowercase
120
121 if clean_ext in SUPPORTED_FILETYPES:
122 _log.info('Found file extension in supported filetypes')
123 return MEDIA_TYPE
124 else:
125 _log.debug('Media present, extension not found in {0}'.format(
126 SUPPORTED_FILETYPES))
127 else:
128 _log.warning('Need additional information (keyword argument \'media\')'
129 ' to be able to handle sniffing')
130
131 return None
132
133
134 class ProcessImage(object):
135 """Code to process an image. Will be run by celery.
136
137 A Workbench() represents a local tempory dir. It is automatically
138 cleaned up when this function exits.
139 """
140 def __init__(self, proc_state=None):
141 if proc_state:
142 self.proc_state = proc_state
143 self.entry = proc_state.entry
144 self.workbench = proc_state.workbench
145
146 # Conversions subdirectory to avoid collisions
147 self.conversions_subdir = os.path.join(
148 self.workbench.dir, 'convirsions')
149
150 self.orig_filename = proc_state.get_orig_filename()
151 self.name_builder = FilenameBuilder(self.orig_filename)
152
153 # Exif extraction
154 self.exif_tags = extract_exif(self.orig_filename)
155
156 os.mkdir(self.conversions_subdir)
157
158 def reprocess_action(self, args):
159 """
160 List the available actions for media in a given state
161 """
162 if args[0].state == 'processed':
163 print _('\n Available reprocessing actions for processed images:'
164 '\n \t --resize: thumb or medium'
165 '\n Options:'
166 '\n \t --size: max_width max_height (defaults to'
167 'config specs)')
168 return True
169
170 def _parser(self, args):
171 """
172 Parses the unknown args from the gmg parser
173 """
174 parser = argparse.ArgumentParser()
175 parser.add_argument(
176 '--resize',
177 choices=['thumb', 'medium'])
178 parser.add_argument(
179 '--size',
180 nargs=2,
181 metavar=('max_width', 'max_height'),
182 type=int)
183 parser.add_argument(
184 '--initial_processing',
185 action='store_true')
186
187 return parser.parse_args(args[1])
188
189 def _check_eligible(self, entry_args, reprocess_args):
190 """
191 Check to see if we can actually process the given media as requested
192 """
193
194 if entry_args.state == 'processed':
195 if reprocess_args.initial_processing:
196 raise Exception(_('You can not run --initial_processing on'
197 ' media that has already been processed.'))
198
199 if entry_args.state == 'failed':
200 if reprocess_args.resize:
201 raise Exception(_('You can not run --resize on media that has'
202 ' not been processed.'))
203 if reprocess_args.size:
204 _log.warn('With --initial_processing, the --size flag will be'
205 ' ignored.')
206
207 if entry_args.state == 'processing':
208 raise Exception(_('We currently do not support reprocessing on'
209 ' media that is in the "processing" state.'))
210
211 def initial_processing(self):
212 # Is there any GPS data
213 gps_data = get_gps_data(self.exif_tags)
214
215 # Always create a small thumbnail
216 resize_tool(self.proc_state, True, 'thumb', self.orig_filename,
217 self.name_builder.fill('{basename}.thumbnail{ext}'),
218 self.conversions_subdir, self.exif_tags)
219
220 # Possibly create a medium
221 resize_tool(self.proc_state, False, 'medium', self.orig_filename,
222 self.name_builder.fill('{basename}.medium{ext}'),
223 self.conversions_subdir, self.exif_tags)
224
225 # Copy our queued local workbench to its final destination
226 self.proc_state.copy_original(self.name_builder.fill('{basename}{ext}'))
227
228 # Remove queued media file from storage and database
229 self.proc_state.delete_queue_file()
230
231 # Insert exif data into database
232 exif_all = clean_exif(self.exif_tags)
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)
241
242 def reprocess(self, reprocess_info):
243 """
244 This function actually does the reprocessing when called by
245 ProcessMedia in gmg/processing/task.py
246 """
247 new_size = None
248
249 # Did they specify a size? They must specify either both or none, so
250 # we only need to check if one is present
251 if reprocess_info.get('max_width'):
252 max_width = reprocess_info['max_width']
253 max_height = reprocess_info['max_height']
254
255 new_size = (max_width, max_height)
256
257 resize_tool(self.proc_state, False, reprocess_info['resize'],
258 self.name_builder.fill('{basename}.medium{ext}'),
259 self.conversions_subdir, self.exif_tags, new_size)
260
261 def media_reprocess(self, args):
262 """
263 This function handles the all of the reprocessing logic, before calling
264 gmg/submit/lib/run_process_media
265 """
266 reprocess_args = self._parser(args)
267 entry_args = args[0]
268
269 # Can we actually process the given media as requested?
270 self._check_eligible(entry_args, reprocess_args)
271
272 # Do we want to re-try initial processing?
273 if reprocess_args.initial_processing:
274 for id in entry_args.media_id:
275 entry = MediaEntry.query.filter_by(id=id).first()
276 run_process_media(entry)
277
278 # Are we wanting to resize the thumbnail or medium?
279 elif reprocess_args.resize:
280
281 # reprocess all given media entries
282 for id in entry_args.media_id:
283 entry = MediaEntry.query.filter_by(id=id).first()
284
285 # For now we can only reprocess with the original file
286 if not entry.media_files.get('original'):
287 raise Exception(_('The original file for this media entry'
288 ' does not exist.'))
289
290 reprocess_info = self._get_reprocess_info(reprocess_args)
291 run_process_media(entry, reprocess_info=reprocess_info)
292
293 # If we are here, they forgot to tell us how to reprocess
294 else:
295 _log.warn('You must set either --resize or --initial_processing'
296 ' flag to reprocess an image.')
297
298 def _get_reprocess_info(self, args):
299 """ Returns a dict with the info needed for reprocessing"""
300 reprocess_info = {'resize': args.resize}
301
302 if args.size:
303 reprocess_info['max_width'] = args.size[0]
304 reprocess_info['max_height'] = args.size[1]
305
306 return reprocess_info
307
308
309 class CommonImageProcessor(MediaProcessor):
310 """
311 Provides a base for various media processing steps
312 """
313 # Common resizing step
314 def resize_step(self):
315 pass
316
317 @classmethod
318 def _add_width_height_args(cls, parser):
319 parser.add_argument(
320 "--width", default=None,
321 help=(
322 "Width of the resized image (if not using defaults)"))
323 parser.add_argument(
324 "--height", default=None,
325 help=(
326 "Height of the resized image (if not using defaults)"))
327
328
329 class InitialProcessor(CommonImageProcessor):
330 """
331 Initial processing step for new images
332 """
333 name = "initial"
334 description = "Initial processing"
335
336 @classmethod
337 def media_is_eligible(cls, media_entry):
338 """
339 Determine if this media type is eligible for processing
340 """
341 return media_entry.state in (
342 "unprocessed", "failed")
343
344 ###############################
345 # Command line interface things
346 ###############################
347
348 @classmethod
349 def generate_parser(cls):
350 parser = argparse.ArgumentParser(
351 description=cls.description,
352 prog=cls.name)
353
354 cls._add_width_height_args(parser)
355
356 return parser
357
358 @classmethod
359 def args_to_request(cls, args):
360 return request_from_args(
361 args, ['width', 'height'])
362
363
364
365 class ImageProcessingManager(ProcessingManager):
366 def __init__(self):
367 super(self.__class__, self).__init__()
368 self.add_processor(InitialProcessor)
369
370
371 if __name__ == '__main__':
372 import sys
373 import pprint
374
375 pp = pprint.PrettyPrinter()
376
377 result = extract_exif(sys.argv[1])
378 gps = get_gps_data(result)
379 clean = clean_exif(result)
380 useful = get_useful(clean)
381
382 print pp.pprint(
383 clean)